feat: stream production sprite maps with fixed per-body framing
@@ -1,6 +1,6 @@
1
1
# Paperdoll Renderer
2
2
3
Desktopeditor voor `.ppd`-projecten, modelimport en een geanimeerde 3D-preview.
3
Desktopeditor voor `.ppd`-projecten, bone-gebonden equipment en geanimeerde 3D-preview, met productie-export naar paperdoll-maps.
4
4
5
5
## Starten
6
6
@@ -21,10 +21,10 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
21
21
22
22
**Open man / Open vrouw** opent de body. Een geskind glTF/GLB-model is in de live preview zichtbaar:
23
23
24
- Sleep om te orbiten, scroll om te zoomen. Een 24-bits depthbuffer zorgt dat voorste oppervlakken achterliggende delen afdekken.
24
- Sleep om te orbiten. **Vast renderkader tonen** toont het gele orthografische exportkader; stel per man/vrouw **Vaste renderhoogte** en **Centrum Y** in en sla op. Body en equipment gebruiken bij export altijd ditzelfde kader. Uitvinken geeft vrije perspectief-preview met scrollzoom; die zoom verandert de export niet. Een echte 24-bits depthbuffer houdt voorste oppervlakken vooraan.
25
25
- Kies een animatieclip en gebruik **Pauze / Speel**.
26
26
- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -30 / 0 / 30** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).
27
- **Render** maakt een gekleurd PNG-stilbeeld van de huidige pose en camera in `renders/body-male-still.png` of `body-female-still.png`. De preview toont achtereenvolgens de hoge resolutie en de ingestelde doelresolutie, met nearest-neighbor uitvergroot en een voortgangsbalk. Iedere pass behoudt zijn beeldverhouding en staat gecentreerd met zwarte balken waar nodig. **Live 3D** keert terug naar het model.
27
- **Render alle hoeken** rendert alle animaties × alle frames op project-fps × 36 hoeken. De preview wordt direct vervangen door iedere voltooide pass: highres → detail → target/diffuse → depth → normal → specular. Alle passes behouden hun aspect met gecentreerde letterboxing en nearest-neighbor weergave. Voortgang telt echte passes; **Annuleer render** behoudt geldige gedeeltelijke uitvoer. **Live 3D** keert terug naar het model; een volgende voltooide renderpass kan de preview weer vervangen.
28
28
- Stel breedte, hoogte en fps in de Bodies-weergave in.
29
29
30
30

@@ -53,6 +53,20 @@ cargo run --example equipment_fixture --locked
53
53
54
54
Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.
55
55
56
## Productie-output
57
58
De bodyknop rendert alleen het lichaam; **Render equipment alle hoeken** in de equipment-editor rendert het geselecteerde preset, geanimeerd op het gekozen lichaam maar **zonder body-occlusie of bodypixels**. De vaste camera van dat geslacht blijft gelijk, ook als je in de live preview vrij hebt ingezoomd.
59
60
Per beeld worden **alleen vier target-res PNGs** opgeslagen: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **onbelichte albedo** (basiskleur/texture × vertexkleur, alpha behouden), nooit ingebakken lighting/schaduw/glans. Depth is lineaire 16-bit grayscale+alpha met gemeenschappelijke near/far; normal is view-space RGB+alpha; specular is lineaire sterkte (body nul, equipment inclusief ingestelde shinymap). Highres en detail zijn uitsluitend zichtbare hulpstappen en worden niet opgeslagen. De latere 2D-lichtcompositor en rim-effecten zijn nog niet gebouwd.
61
62
```text
63
renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-30_y330_diffuse.png
64
```
65
66
Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet overschreven. `render.json` beschrijft instellingen en telling; alleen `complete.json` betekent een volledige set. `cancelled.json`/`failed.json` markeren een gedeeltelijke run. De map-/camera-/pixelcontracten staan in `design.md`.
67
68
Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. FBX is alleen importoverzicht; morph targets, tangent-normaltextures, volledige PBR en willekeurige transparante lagen zijn niet ondersteund. Live placement blijft een belichte, vereenvoudigde weergave.
69
56
70
## Openen met
57
71
58
72
- **Windows:** start de uitgepakte `.exe` eenmaal om de editor voor het huidige account aan *Openen met* toe te voegen. Kies daarna bij een `.ppd` **Openen met → Paperdoll Renderer** (of selecteer de `.exe` via Bladeren).
@@ -68,7 +82,7 @@ cargo fmt --check
68
82
cargo check --workspace --all-targets --locked
69
83
cargo clippy --workspace --all-targets --locked -- -D warnings
70
84
cargo test --workspace --locked
71
# Linux GPU-regressie (Xvfb + Mesa): echte overlappende geometrie en pixelreadback
85
# Linux GPU-regressie: echte depth-occlusie, normal/depth/specular en lichtonafhankelijke albedo
72
86
xvfb-run -a cargo run --example preview_depth_probe --locked
73
87
```
74
88
@@ -1,6 +1,6 @@1
# Paperdoll Renderer1
# Paperdoll Renderer2
2
3
Desktopeditor voor `.ppd`-projecten, modelimport en een geanimeerde 3D-preview.3
Desktopeditor voor `.ppd`-projecten, bone-gebonden equipment en geanimeerde 3D-preview, met productie-export naar paperdoll-maps.4
4
5
## Starten5
## Starten6
6
@@ -21,10 +21,10 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert21
21
22
**Open man / Open vrouw** opent de body. Een geskind glTF/GLB-model is in de live preview zichtbaar:22
**Open man / Open vrouw** opent de body. Een geskind glTF/GLB-model is in de live preview zichtbaar:23
23
24
- Sleep om te orbiten, scroll om te zoomen. Een 24-bits depthbuffer zorgt dat voorste oppervlakken achterliggende delen afdekken.24
- Sleep om te orbiten. **Vast renderkader tonen** toont het gele orthografische exportkader; stel per man/vrouw **Vaste renderhoogte** en **Centrum Y** in en sla op. Body en equipment gebruiken bij export altijd ditzelfde kader. Uitvinken geeft vrije perspectief-preview met scrollzoom; die zoom verandert de export niet. Een echte 24-bits depthbuffer houdt voorste oppervlakken vooraan.25
- Kies een animatieclip en gebruik **Pauze / Speel**.25
- Kies een animatieclip en gebruik **Pauze / Speel**.26
- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -30 / 0 / 30** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).26
- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -30 / 0 / 30** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).27
- **Render** maakt een gekleurd PNG-stilbeeld van de huidige pose en camera in `renders/body-male-still.png` of `body-female-still.png`. De preview toont achtereenvolgens de hoge resolutie en de ingestelde doelresolutie, met nearest-neighbor uitvergroot en een voortgangsbalk. Iedere pass behoudt zijn beeldverhouding en staat gecentreerd met zwarte balken waar nodig. **Live 3D** keert terug naar het model.27
- **Render alle hoeken** rendert alle animaties × alle frames op project-fps × 36 hoeken. De preview wordt direct vervangen door iedere voltooide pass: highres → detail → target/diffuse → depth → normal → specular. Alle passes behouden hun aspect met gecentreerde letterboxing en nearest-neighbor weergave. Voortgang telt echte passes; **Annuleer render** behoudt geldige gedeeltelijke uitvoer. **Live 3D** keert terug naar het model; een volgende voltooide renderpass kan de preview weer vervangen.28
- Stel breedte, hoogte en fps in de Bodies-weergave in.28
- Stel breedte, hoogte en fps in de Bodies-weergave in.29
29
30
30
@@ -53,6 +53,20 @@ cargo run --example equipment_fixture --locked53
53
54
Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.54
Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.55
55
56
## Productie-output57
58
De bodyknop rendert alleen het lichaam; **Render equipment alle hoeken** in de equipment-editor rendert het geselecteerde preset, geanimeerd op het gekozen lichaam maar **zonder body-occlusie of bodypixels**. De vaste camera van dat geslacht blijft gelijk, ook als je in de live preview vrij hebt ingezoomd.59
60
Per beeld worden **alleen vier target-res PNGs** opgeslagen: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **onbelichte albedo** (basiskleur/texture × vertexkleur, alpha behouden), nooit ingebakken lighting/schaduw/glans. Depth is lineaire 16-bit grayscale+alpha met gemeenschappelijke near/far; normal is view-space RGB+alpha; specular is lineaire sterkte (body nul, equipment inclusief ingestelde shinymap). Highres en detail zijn uitsluitend zichtbare hulpstappen en worden niet opgeslagen. De latere 2D-lichtcompositor en rim-effecten zijn nog niet gebouwd.61
62
```text63
renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-30_y330_diffuse.png64
```65
66
Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet overschreven. `render.json` beschrijft instellingen en telling; alleen `complete.json` betekent een volledige set. `cancelled.json`/`failed.json` markeren een gedeeltelijke run. De map-/camera-/pixelcontracten staan in `design.md`.67
68
Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. FBX is alleen importoverzicht; morph targets, tangent-normaltextures, volledige PBR en willekeurige transparante lagen zijn niet ondersteund. Live placement blijft een belichte, vereenvoudigde weergave.69
56
## Openen met70
## Openen met57
71
58
- **Windows:** start de uitgepakte `.exe` eenmaal om de editor voor het huidige account aan *Openen met* toe te voegen. Kies daarna bij een `.ppd` **Openen met → Paperdoll Renderer** (of selecteer de `.exe` via Bladeren).72
- **Windows:** start de uitgepakte `.exe` eenmaal om de editor voor het huidige account aan *Openen met* toe te voegen. Kies daarna bij een `.ppd` **Openen met → Paperdoll Renderer** (of selecteer de `.exe` via Bladeren).@@ -68,7 +82,7 @@ cargo fmt --check68
cargo check --workspace --all-targets --locked82
cargo check --workspace --all-targets --locked69
cargo clippy --workspace --all-targets --locked -- -D warnings83
cargo clippy --workspace --all-targets --locked -- -D warnings70
cargo test --workspace --locked84
cargo test --workspace --locked71
# Linux GPU-regressie (Xvfb + Mesa): echte overlappende geometrie en pixelreadback85
# Linux GPU-regressie: echte depth-occlusie, normal/depth/specular en lichtonafhankelijke albedo72
xvfb-run -a cargo run --example preview_depth_probe --locked86
xvfb-run -a cargo run --example preview_depth_probe --locked73
```87
```74
88
@@ -1,7 +1,7 @@
1
1
# Paperdoll Renderer — design
2
2
3
3
**Status: design recorded with a foundation shell only.**
4
Prepared 6 November 2026 (Europe/Amsterdam). Requirements originate with Tim Falken. Sections explicitly marked **proposal** remain implementation decisions to validate; the verbatim appendix is authoritative for user intent. The Rust program shipped with this design is the section 12 step 0 shell: it compiles, opens a status window, and tells the truth about what is not built. It does not import models and it does not render paperdolls. Synthetic test bodies, when present under `tests/fixtures/bodies/`, are fixtures for later features; this program does not load them.
4
Prepared 6 November 2026 (Europe/Amsterdam). Requirements originate with Tim Falken. Sections marked **proposal** remain future decisions; the appendix preserves the original brief. Current functionality is described in §§5–11: project/equipment authoring, shared-depth live 3D and streaming all-clips/frames/36-view production target maps are implemented. Later corrections (target-only four maps and strictly unlit diffuse) supersede older filename/rim wording in the historical appendix.
5
5
6
6
Anonymous HTTP requests to `https://git.timfalken.com/groups/golden-age` and `https://git.timfalken.com/golden-age/paperdoll-renderer` returned 404. BlueGit also answers 404 when a private project is not visible to the caller, so those responses do not prove the private repository is absent. This workspace does not create the group or the repository. The requested BlueGit destination stays group **Golden Age**, slug `golden-age`, repository **Paperdoll Renderer**, slug `paperdoll-renderer`.
7
7
@@ -67,7 +67,7 @@ The desktop shell stays on eframe/egui. Bevy is not linked. Pulling Bevy in only
67
67
68
68
This build reads glTF 2.0 `.gltf` and `.glb`: mesh positions, triangle indices, normals, UVs, skin weights, materials (base color and named textures), images (external files and buffer views), joint names with parents, and every animation clip (name, duration, channel count, target joints). It reads OBJ+MTL as a static equipment mesh, including referenced texture files, and always records that OBJ has no skeleton and no skeletal clips. External URIs that leave the project directory are rejected. A missing buffer, image or MTL file fails the import and names the missing path.
69
69
70
FBX is read with the `ufbx` crate 0.11.5. That crate compiles the MIT-licensed ufbx C loader into the binary, so AppImage, `.deb`, Arch and the MinGW Windows build do not depend on a system `libassimp`. `russimp` was not used: it needs a native Assimp build whose FBX animation results and Windows packaging are still the open risk above, and ufbx is the loader that actually returns skins, clips and materials for the fixture. A file that is not FBX still fails the parse; the `.fbx` suffix alone is not accepted. The live viewport samples glTF/GLB. FBX stays in the import inventory, and the orthographic paperdoll export is later work, recorded in section 11. The fixture `tests/fixtures/bodies/test-body-male.fbx` is produced from the CC0 GLB by `scripts/convert_test_body_to_fbx.py` (Blender is a fixture tool only; the application does not call it).
70
FBX is read with the `ufbx` crate 0.11.5. That crate compiles the MIT-licensed ufbx C loader into the binary, so AppImage, `.deb`, Arch and the MinGW Windows build do not depend on a system `libassimp`. `russimp` was not used: it needs a native Assimp build whose FBX animation results and Windows packaging are still the open risk above, and ufbx is the loader that actually returns skins, clips and materials for the fixture. A file that is not FBX still fails the parse; the `.fbx` suffix alone is not accepted. The live viewport samples glTF/GLB. FBX stays in the import inventory, and the production orthographic export now renders glTF/GLB, not FBX. The fixture `tests/fixtures/bodies/test-body-male.fbx` is produced from the CC0 GLB by `scripts/convert_test_body_to_fbx.py` (Blender is a fixture tool only; the application does not call it).
71
71
72
72
| Format | Mesh | Materials/textures | Skeleton | Animation | Intended role / status |
73
73
|---|---|---|---|---|---|
@@ -94,48 +94,59 @@ Suggested layout: `character.ppd`, `assets/body/`, `assets/equipment/`, `presets
94
94
95
95
Validate finite numeric values, dimensions > 0, bounded dimensions and memory estimate, positive FPS, valid clip references, existing model/preset paths and output directories distinct from sources. A missing optional sex slot is a valid project, but cannot render that slot. No absolute-path leakage into exported manifests unless user explicitly chooses external references.
96
96
97
### format_version 1
97
### Implemented format_version 2 (version 1 migration)
98
98
99
Implemented project file: one UTF-8 JSON manifest per project directory, default name `character.ppd`. Fields are `format_version` (exactly 1), `id`, `name`, `male_model`, `female_model`, `equipment_presets`, `output_directory`, and `render` with `target_width`, `target_height` and `fps`. Sex slots are JSON null when empty. Paths use `/` and stay inside the project directory. A preset file stem is a lowercase slug and is not `body`. Unknown fields are rejected, so a rewrite cannot drop them. Any other `format_version` is rejected before the rest of the object is accepted. Save writes a temporary sibling in the same directory and renames it over the manifest only after the bytes are on disk. A failed save leaves the previous manifest in place. Naming a model file does not import or render it. Animation inventory and import transforms are not part of version 1.
99
`character.ppd` is a strict UTF-8 JSON manifest. Fields remain `format_version`, `id`, `name`, nullable `male_model`/`female_model`, `equipment_presets`, `output_directory`, and `render` (`target_width`, `target_height`, `fps`). Version 2 adds `render.framing`, an array **[male, female]** of `{height_milli, center_y_milli}`: integer world-unit millimetres, default height 2600 and center Y 1000. Heights 100…10000 and centers −10000…10000 are validated. Each sex has its own fixed camera, shared by every body/equipment export. Opening version 1 migrates in memory with default framing; the next save writes version 2. Other versions and unknown fields are rejected, not silently discarded. This does not add source-axis/import-transform overrides or animation inventory overrides.
100
101
Paths use `/` and stay inside the project directory. Save writes/fsyncs a temporary sibling and atomically replaces the manifest; failure preserves the prior manifest. Binary equipment snapshots remain separate. Per-sex framing is part of the normal unsaved-change/save/discard gate, not transient orbit state.
100
102
101
103
## 6. Camera, axes, alignment and animation sampling
102
104
103
105
Required views: pitches -30, 0, +30 degrees; yaw 0, 30, 60, ..., 330 degrees. **Proposal:** yaw zero means the standardized character front faces the camera, world +Y is up, and positive yaw is defined by a documented right-hand convention. Distinguish camera elevation from model rotation: use orbiting orthographic camera around fixed character origin. Golden orientation fixture must show labelled front/back/left/right to remove ambiguity before production export.
104
106
105
Use orthographic projection for aligned layering. Shared camera framing, pivot, near/far, canvas size and unit normalization are stored per sex/export set and must match across body/equipment. Determine a stable envelope across selected animations, with configurable margin and equipment allowance. Never independently auto-frame each equipment piece or animation frame. Source-model forward-axis correction is explicit in project import settings.
107
**Implemented fixed framing:** orthographic projection, +Y world up, yaw 0 camera on −Z looking toward the character; positive yaw follows the existing orbit convention. Orbit target is `(0, center_y_milli/1000, 0)`, camera distance **10**, vertical world extent `height_milli/1000`, horizontal extent vertical × target aspect. Near **0.05**, far **40** are shared absolutely across all layers, frames and views. No per-frame/per-equipment auto-framing or depth normalization. Source root motion is retained as imported (no extraction). User adjusts height/center in the body view to allow all animation/equipment extents; this is manual, not a promised automatic animation envelope.
106
108
107
**Proposal:** sample looping clips at t=i/FPS for 0 <= i < ceil(duration*FPS), excluding a duplicate terminal sample; validate zero-duration clips and offer a bind/static frame instead. Store actual sample timestamps and duration in output metadata. Root motion is initially in-place with a documented extraction policy; preserve original clips and allow future explicit mode. No interpolation of ordinal frames as though every clip had the same duration.
109
**Vast renderkader tonen** switches live placement to this same orthographic camera and target-aspect, centered yellow frame. Drag still changes yaw/pitch; orbit-scroll zoom is ignored while the fixed frame is shown. Uncheck it for the independent perspective orbit/scroll camera. Neither free orbit zoom nor live selected clip/time changes a production job. Render snapshots the current project settings at job start; later edits do not change that in-flight batch. **Opslaan** persists male and female fixed framing in `.ppd`.
108
110
109
**Confirmed production Render behavior (next feature after equipment):** enumerate **all animation clips × all 36 angles × every frame sampled at project FPS**, processing one pose/view and its real passes/maps at a time. Do not stop at the currently selected preview clip, angle or frame. Deterministic proposed nesting is source clip order → pitch -30/0/+30 → yaw 0…330 → frame → pass/map order from §8. Replace the preview after each completed real pass, immediately and without added delays. This is a full production batch requirement; the currently shipped Render action is still explicitly a single shaded body still, not this batch.
111
**Implemented batch nesting:** source clip order → sampled frame → pitch **−30,0,+30** → yaw **0…330 step30** → six completed passes. Samples are `t=frame/FPS`, `0 <= frame < max(1, ceil(duration*FPS))`. To avoid exporter float noise creating a near-duplicate terminal frame, the product snaps to its nearest integer when within `4*f32::EPSILON*max(1,product)` before ceiling; a regression covers an exported 1-second endpoint one ULP above 1. Otherwise the full fractional duration is sampled; a zero-duration clip has one static sample; no clips produces one bind-pose clip. Duplicate display names get indexed tokens and internally unique sampling names, not accidental reuse of the first clip. All clips/frames/views are rendered regardless of current live selection. FPS is sampling, not playback rate.
110
112
111
The 8x requirement means **8x width AND 8x height**, hence 64x pixel count. FPS controls animation sampling rather than interactive preview performance. Render maps in a bounded streaming pipeline, not all frames in RAM. Estimate disk/GPU/memory cost before starting: frame count times 36 views times map count. Cancellation stops between safe work units and leaves an incomplete marker, never an apparently complete asset set.
113
Highres is exactly **8× width AND height** (64× pixels); targets currently **1…256 per axis**, highres max **2048 per axis**. Larger projects may be opened but their batch render is explicitly rejected, never silently reduced to 1×. A batch is limited to 500000 PNGs and 16 GiB estimated uncompressed target data. Render streams one pose/view at a time; no whole-batch image allocation. GPU service is one offscreen draw/readback unit per native UI update, with real depth24 attachment/test/write/clear; skinning, detail/resolve and PNG writes run on a background worker. The GUI keeps updating; a large individual GPU unit can still take a frame. The latest-pass mailbox retains **one** completed image, one progress update, and a terminal event; no unbounded preview backlog. No sleeps, pass playback delay or preview acknowledgment. Fast passes can be visually skipped, never faked.
112
114
113
115
## 7. Export naming and metadata
114
116
115
Required schema: `<geslacht>-<settype>-<animatienaam>-<pitch>-<yaw>-<maptype>-f<framenummer>.png`.
117
The current hierarchical layout supersedes the earlier flat filename proposal (the original brief is retained verbatim in the historical appendix):
118
119
```text
120
renders/run-<unix-nanoseconds>-<pid>/<male|female>/<body|equipment-slug>/
121
clip<source-index:03>-<safe-name>/f<frame:06>/p<pitch:+03>_y<yaw:03>_<map>.png
122
# example: .../male/body/clip001-walk/f000012/p-30_y330_diffuse.png
123
```
116
124
117
**Proposal:** sex tokens `male` and `female`; body set type exactly `body`; equipment name validated as a lowercase slug without spaces, reject reserved `body`. Use deterministic safe animation slugs plus collision registry preserving original names in a companion manifest. Decide delimiter escaping explicitly: negative pitch creates adjacent hyphens, so parse from the known suffix rather than naive splitting. Example `male-body-walk--30-0-diffuse-f000000.png` corresponds to pitch -30/yaw 0/frame 0. A six-digit frame width is a provisional minimum, not a cap; width and naming are stored in metadata.
125
`output_directory` may change the `renders/` root. Safe lowercase clip slugs replace non-ASCII/non-alphanumerics with `-`, truncate to 48 characters, and prefix source index (so duplicate names cannot collide). Equipment slugs use the existing strict preset validator; `body` is reserved. Frame index is zero-based. Map tokens are **only `diffuse`, `depth`, `normal`, `specular`**, for body AND equipment, all exactly target width/height. **Highres and detail are preview-only intermediates: never PNG exports.** Each run is a fresh directory, never replaces any previous run or named image. Canonical parent checks reject traversal/outside symlinks; sibling temporary file + fsync + no-clobber hardlink publication prevents a partial PNG appearing as a valid final file. Filesystems must support hardlinks; unsupported filesystems fail explicitly. Existing or foreign temporary files are never deleted on creation failure.
118
126
119
The companion output manifest stores original clip IDs/names, name tokens, timestamps, dimensions, FPS, camera matrices/conventions, shared depth range, normal space, alpha mode, specular convention, render settings/version, source fingerprints and completion status. This metadata supplements rather than replaces the required PNG filenames. Output writes use temporary files and explicit overwrite choice. Sanitize names without creating collisions or interpreting slashes as directories.
127
`render.json` (output metadata schema 1, separate from `.ppd` schema 2) records sex/set, target, FPS, highres scale, framing, common near/far, conventions, source clip names/tokens/durations/frame counts, six-pass order, four saved maps and actual counts. Frame timestamps follow the recorded sampling equation. `complete.json` is published ONLY after all PNGs complete; cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json`. Source-file checksums, per-image checksums, resumable batches and automatic cleanup are future work, not implemented manifest fields.
120
128
121
129
## 8. Map contracts and rendering algorithm
122
130
123
Diffuse is an **unlit base color** suitable for later lighting, not an already lit screenshot. Preserve alpha, use sRGB encoding only at output boundaries and process color in linear light. Transparency outside geometry remains zero; distinguish uncovered pixels from valid near-depth pixels.
131
**Diffuse (Tim t953u): unlit albedo only.** Linear material base color × sRGB-decoded base-color texture × linear vertex COLOR_0 (white if absent), then sRGB encode at the PNG boundary. Preserve straight alpha/coverage. **No lighting, shading, shadows, specular or rim accents are baked in.** Live 3D may stay lit. A native GPU texture×vertex-color regression checks exact expected albedo/alpha and byte-identical diffuse under three opposed light directions.
132
133
**Depth:** actual depth24 GPU attachment readback, resolved to **16-bit grayscale+alpha PNG**, same coverage as other maps; `distance = near + (gray/65535)*(far-near)`. Smaller covered depth is nearer. Transparent zero values are invalid, not a near plane. Fixed near/far and camera distance are identical for body/equipment and every frame. No luminance-derived pseudo-depth or per-frame rescaling.
134
135
**Normal:** view-space skinned geometric/shading normal, RGB8 encodes `n*0.5+0.5`, straight coverage alpha. Camera +Y is up; PNG rows are top-down. Normals are transformed by inverse-transpose skin transforms and the common view rotation, then normalized after resolve. Transparent RGB is zero and must be ignored. Tangent-space source normal textures and full PBR material conversion are not implemented.
124
136
125
Depth **proposal:** 16-bit grayscale PNG representing linear camera-space depth normalized to one shared near/far interval for all compatible layers. Smaller valid values mean nearer the viewer. Use diffuse alpha/coverage as validity; never interpret the transparent background as the closest surface. No per-frame or per-equipment depth rescaling. Record the exact decode equation in metadata. Depth ordering tests must include equipment in front of and behind different body regions.
137
**Specular:** RGBA8, linear scalar repeated in RGB, shared coverage alpha; body defaults to zero. Equipment uses its editable strength × shinymap luminance (when present), not a baked highlight. Output token is `specular` (not the old provisional `shiny`). The future consumer chooses the lighting/exponent; this is not physically exact metallic/roughness PBR.
126
138
127
Normals **proposal:** view-space geometric/shading normal, xyz encoded from [-1,1] to [0,1] in RGB PNG, with documented image Y convention; normal space must match the preview light transform. Normalize after interpolation/filtering. Uncovered pixels have alpha zero and a neutral placeholder normal. Include tangent-space source normal maps where supported and explicitly test their conversion; a geometry-normal bake is not automatically full source-material normal fidelity.
139
Per pose/view, the actual completed-preview order is:
140
1. **Highres:** evaluate skeleton/attachments at exact sample time; offscreen MRT draws unlit albedo, view normal, scalar specular and true depth at exact 8× dimensions. Publish highres albedo immediately, no file saved.
141
2. **Detail:** color/alpha/depth/normal neighbor discontinuities, bounded weights. Publish actual edge map immediately, no file saved.
142
3. **Target-res / diffuse:** for each 8×8 block compute coverage, choose nearest covered depth and a coherent front-surface cluster (depth tolerance 0.0025 of the common near/far interval), apply alpha × `(1+4*detail)` weights, and resolve albedo in linear light. Color/normal/specular all use that same cluster, coverage comes from all covered samples; this explicit front-feature retention policy is an approximation, not unbiased area filtering. Normalize normal. Write target diffuse, publish NN-upscaled preview.
143
4. **Depth:** write target 16-bit gray+alpha, publish its gray8 visualization (the saved depth keeps 16-bit precision).
144
5. **Normal:** write/publish target normal.
145
6. **Specular:** write/publish target scalar map, including body zero.
128
146
129
Equipment shinymap **proposal:** grayscale scalar specular strength (0..1), with a documented default exponent/roughness in metadata. Filename token `shiny` is provisional because Tim named the concept but did not mandate a token spelling. Metallic/roughness and Phong materials do not map uniquely to one scalar; expose a conservative conversion/default and retain editable overrides. Do not label it physically exact PBR. Body has diffuse/depth/normal; equipment additionally shiny.
147
Every pass uses one uniform scale, centered letterbox, nearest-neighbor display; never stretch target X/Y independently. The progress bar counts actual completed passes out of `images*6`, and a separate label identifies the last completed image while a later pass works. Each pose saves exactly four target PNGs. **Annuleer render** stops between safe units (or after the in-flight GPU readback), keeps partial outputs and never produces a complete marker. Rim lighting and the 2D lighting/compositor remain deferred; any future rim belongs in the consumer, not in pure diffuse albedo.
130
148
131
Pipeline per shared scene sample/view:
132
1. Evaluate skeleton and bone world transforms at the exact sample time.
133
2. Render high-resolution unlit color/coverage and geometry buffers at 8x target dimensions. Depth/normals originate from the actual same surfaces, not image luminance.
134
3. Compute an internal detail map using color gradients plus alpha/depth/normal discontinuities, bounded weights and transparent-neighbor handling. Detail map is intermediate, not a fourth body output map.
135
4. Downsample diffuse with deterministic coverage-aware weighted reconstruction in linear premultiplied color; unpremultiply safely for straight-alpha PNG output. Tune thin-feature retention on fixtures, not on a named but unimplemented algorithm.
136
5. Resolve target-resolution depth/normals from coherent contributing surfaces. Avoid mixing background with foreground or averaging front and back depths into nonphysical geometry. Align the chosen representative/coverage policy across maps. Renormalize normals.
137
6. Add a subtle configurable rim/detail accent to diffuse based on normal/depth discontinuities, never a light-direction bake. Clamp magnitude, preserve alpha; default must be validated visually to avoid double-lighting later.
138
7. Encode all target-resolution PNGs atomically and record checksums/metadata. Test deterministic CPU stages independently from GPU tolerance.
149
Current source scope: the first skinned glTF/GLB mesh (all its triangle primitives), first skin, vertex NORMAL attributes when present, otherwise true flat normals from GPU derivatives of the skinned view-space triangle position (never the live-only fallback vector), base-color factors/textures and COLOR_0; albedo images must be 8-bit and ≤2048×2048. Textures currently use nearest/repeat; sampler overrides/texture transforms, morph targets, tangent normal maps, multi-mesh scenes and arbitrary translucent layer ordering are not claimed. Opaque/alpha-cutout uses one winning surface; this is not order-independent blended transparency. GPU limits also reject non-finite geometry, more than 1M vertices/3M indices per draw, invalid texture payloads and invalid camera matrices. FBX remains inventory-only.
139
150
140
151
Equipment pass hides the body from **all color, depth, normal and specular writes**, while still evaluating its rig and animating attachments. The body is not an invisible occluder; otherwise equipment behind it would be missing when layers are recomposed. Equipment self-occludes normally. Every layer must use the same projection and absolute depth contract. Hair/alpha cutout versus true blended transparency is an open fidelity decision; initial opaque/cutout support must be stated rather than promising arbitrary multilayer transparency from a single depth value.
141
152
@@ -175,13 +186,13 @@ Depth compositor selects the nearest valid surface per pixel from body and chose
175
186
176
187
## 11. Development workflow, quality and packaging
177
188
178
Each feature starts on its own `feature/...` branch from current master. Implement its tests and acceptance criteria, run local checks, commit, then `git merge --no-ff` into master and push. No initial-phase MRs. Keep branch/feature and merge SHAs in the coordinator status file, report completion before the next feature. Setup/design may use `feature/project-design`; establish a baseline master commit first if required to obtain a genuine two-parent merge. Never fabricate a merge on an unborn branch.
189
Each feature starts on its own `feature/...` branch from current master. Implement its tests and acceptance criteria, run local checks, commit, then `git merge --no-ff` into master and push. No initial-phase MRs. Keep branch/feature and merge SHAs in the coordinator status file, report completion before the next feature. For this production-render task the latest explicit instruction overrides publication: merge/cleanup locally only, **no push, tags, releases or package builds**; the parent handles subsequent publication. Setup/design may use `feature/project-design`; establish a baseline master commit first if required to obtain a genuine two-parent merge. Never fabricate a merge on an unborn branch.
179
190
180
191
After every merge, `README.md` describes only the current behavior: what the program can do and how to use it. It does not list absent features, a roadmap, or negative claims. A screenshot belongs in the README only when it shows how to use the program. This convention applies to every later feature. Design limits stay in this file.
181
192
182
193
The application icon is artwork supplied by Tim Falken. The original file is `assets/icons/paperdoll-renderer.png` (57×60 RGBA). Package bitmaps are scaled from that file with a premultiplied Lanczos filter, so the drawing is not redrawn. The eframe window uses the 128×128 raster as its window icon. Larger hicolor sizes stay in the packages. Linux AppImage, `.deb` and Arch packages install the hicolor sizes and name the icon `paperdoll-renderer` from both the desktop file and the AppStream metainfo. The MinGW Windows build embeds the same artwork in `paperdoll-renderer.exe` as `RT_GROUP_ICON`; the zip does not carry a separate `.ico`.
183
194
184
Live 3D preview, as built: the body screen of the eframe window draws the skinned glTF/GLB mesh with a perspective orbit camera. Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. It is not the orthographic paperdoll export, and it does not draw OBJ or FBX. The shell explicitly requests a native 24-bit depth buffer in eframe's **Glow/OpenGL** backend (not wgpu). Every body callback owns depth-test LESS, depth writes and clear-to-1 each frame; subsequent equipment layers must share that attachment without clearing it. A real GPU overlap/readback probe checks draw-order independence and depth clearing between frames. The CPU still rasterizer also depth-tests finite in-frustum fragments; neither preview is allowed to use painter's submission order as occlusion.
195
Live 3D preview, as built: the body screen draws the skinned glTF/GLB mesh with an independent perspective orbit camera or the visible fixed orthographic render frame (§6). Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. The placement preview is lit, unlike exported albedo. It overlays bone-attached OBJ equipment (§9); FBX is not drawn. The shell explicitly requests a native 24-bit depth buffer in eframe's **Glow/OpenGL** backend (not wgpu). Every body callback owns depth-test LESS, depth writes and clear-to-1 each frame; subsequent equipment layers must share that attachment without clearing it. A real GPU overlap/readback probe checks draw-order independence and depth clearing between frames. The CPU still rasterizer also depth-tests finite in-frustum fragments; neither preview is allowed to use painter's submission order as occlusion.
185
196
186
197
CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.
187
198
@@ -204,7 +215,7 @@ These requirements are durable even where the implementation is staged. They sup
204
215
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
205
216
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
206
217
207
Current implementation checkpoint: startup/native pickers, seeded projects, body-centric navigation, equipment name/model CRUD, unsaved-change gate, CLI project opening, platform integration assets, icon and live animated glTF preview are implemented. Stage 4 equipment authoring is implemented as described in §9, with binary embedded OBJ presets and actual animated bone-attached placement preview; legacy JSON records remain readable. The current **Render** action exports a shaded color still of the selected glTF pose/camera through high-resolution rasterization and target-resolution nearest sampling, with two real pass previews and progress. It does not claim production sprite exports: full 36-view/animation batching, orthographic framing, detail-aware reconstruction and diffuse/depth/normal/shiny/rim maps remain stages 5–6 above. The complete requested preview order is preserved here and in `PASS_ORDER` for that work; unimplemented passes are excluded from the running job’s progress. High-resolution stills use up to 8× dimensions within a 1024-pixel high-resolution staging limit for small targets; larger valid targets use scale 1. This is a preview/still renderer, not the production contract in §8.
218
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview and fixed per-sex framing are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The 2D relighting/compositor, rim effects, resume and FBX drawing remain unimplemented.
208
219
209
220
## 12. Incremental feature plan and acceptance gates
210
221
@@ -291,7 +302,7 @@ Minimal server change, not applied in this repository: add `POST /api/v1/repos/{
291
302
292
303
Each file has one skin, 19 named joints, rigid skin weights, and two clips: `walk` (1 second, arms opposite the legs) and `attack` (0.8 seconds, the right arm swings forward). Bind rotations are identity. The documented axes are +Y up and forward -Z. Male and female differ in shoulder width, hip width and hip height so a later importer can tell them apart.
293
304
294
`tests/test_body_assets.rs` checks that both files exist, parse as GLB, contain that skeleton, contain both clips, and byte-match a fresh run of the generator. That test is a fixture check. It is not the product importer, it does not play the clips, and it does not render. The desktop shell still reports glTF/GLB as not built and does not open these files.
305
`tests/test_body_assets.rs` checks that both files exist, parse as GLB, contain that skeleton, contain both clips, and byte-match a fresh run of the generator. That test is a fixture check. It is not the product importer, it does not play the clips, and it does not render. The current editor imports and animates these CC0 fixtures; native GPU and batch probes test actual rendering separately from this asset-structure test.
295
306
296
307
# Appendix A — Tim's original proposal (verbatim)
297
308
@@ -1,7 +1,7 @@1
# Paperdoll Renderer — design1
# Paperdoll Renderer — design2
2
3
**Status: design recorded with a foundation shell only.**3
**Status: design recorded with a foundation shell only.**4
Prepared 6 November 2026 (Europe/Amsterdam). Requirements originate with Tim Falken. Sections explicitly marked **proposal** remain implementation decisions to validate; the verbatim appendix is authoritative for user intent. The Rust program shipped with this design is the section 12 step 0 shell: it compiles, opens a status window, and tells the truth about what is not built. It does not import models and it does not render paperdolls. Synthetic test bodies, when present under `tests/fixtures/bodies/`, are fixtures for later features; this program does not load them.4
Prepared 6 November 2026 (Europe/Amsterdam). Requirements originate with Tim Falken. Sections marked **proposal** remain future decisions; the appendix preserves the original brief. Current functionality is described in §§5–11: project/equipment authoring, shared-depth live 3D and streaming all-clips/frames/36-view production target maps are implemented. Later corrections (target-only four maps and strictly unlit diffuse) supersede older filename/rim wording in the historical appendix.5
5
6
Anonymous HTTP requests to `https://git.timfalken.com/groups/golden-age` and `https://git.timfalken.com/golden-age/paperdoll-renderer` returned 404. BlueGit also answers 404 when a private project is not visible to the caller, so those responses do not prove the private repository is absent. This workspace does not create the group or the repository. The requested BlueGit destination stays group **Golden Age**, slug `golden-age`, repository **Paperdoll Renderer**, slug `paperdoll-renderer`.6
Anonymous HTTP requests to `https://git.timfalken.com/groups/golden-age` and `https://git.timfalken.com/golden-age/paperdoll-renderer` returned 404. BlueGit also answers 404 when a private project is not visible to the caller, so those responses do not prove the private repository is absent. This workspace does not create the group or the repository. The requested BlueGit destination stays group **Golden Age**, slug `golden-age`, repository **Paperdoll Renderer**, slug `paperdoll-renderer`.7
7
@@ -67,7 +67,7 @@ The desktop shell stays on eframe/egui. Bevy is not linked. Pulling Bevy in only67
67
68
This build reads glTF 2.0 `.gltf` and `.glb`: mesh positions, triangle indices, normals, UVs, skin weights, materials (base color and named textures), images (external files and buffer views), joint names with parents, and every animation clip (name, duration, channel count, target joints). It reads OBJ+MTL as a static equipment mesh, including referenced texture files, and always records that OBJ has no skeleton and no skeletal clips. External URIs that leave the project directory are rejected. A missing buffer, image or MTL file fails the import and names the missing path.68
This build reads glTF 2.0 `.gltf` and `.glb`: mesh positions, triangle indices, normals, UVs, skin weights, materials (base color and named textures), images (external files and buffer views), joint names with parents, and every animation clip (name, duration, channel count, target joints). It reads OBJ+MTL as a static equipment mesh, including referenced texture files, and always records that OBJ has no skeleton and no skeletal clips. External URIs that leave the project directory are rejected. A missing buffer, image or MTL file fails the import and names the missing path.69
69
70
FBX is read with the `ufbx` crate 0.11.5. That crate compiles the MIT-licensed ufbx C loader into the binary, so AppImage, `.deb`, Arch and the MinGW Windows build do not depend on a system `libassimp`. `russimp` was not used: it needs a native Assimp build whose FBX animation results and Windows packaging are still the open risk above, and ufbx is the loader that actually returns skins, clips and materials for the fixture. A file that is not FBX still fails the parse; the `.fbx` suffix alone is not accepted. The live viewport samples glTF/GLB. FBX stays in the import inventory, and the orthographic paperdoll export is later work, recorded in section 11. The fixture `tests/fixtures/bodies/test-body-male.fbx` is produced from the CC0 GLB by `scripts/convert_test_body_to_fbx.py` (Blender is a fixture tool only; the application does not call it).70
FBX is read with the `ufbx` crate 0.11.5. That crate compiles the MIT-licensed ufbx C loader into the binary, so AppImage, `.deb`, Arch and the MinGW Windows build do not depend on a system `libassimp`. `russimp` was not used: it needs a native Assimp build whose FBX animation results and Windows packaging are still the open risk above, and ufbx is the loader that actually returns skins, clips and materials for the fixture. A file that is not FBX still fails the parse; the `.fbx` suffix alone is not accepted. The live viewport samples glTF/GLB. FBX stays in the import inventory, and the production orthographic export now renders glTF/GLB, not FBX. The fixture `tests/fixtures/bodies/test-body-male.fbx` is produced from the CC0 GLB by `scripts/convert_test_body_to_fbx.py` (Blender is a fixture tool only; the application does not call it).71
71
72
| Format | Mesh | Materials/textures | Skeleton | Animation | Intended role / status |72
| Format | Mesh | Materials/textures | Skeleton | Animation | Intended role / status |73
|---|---|---|---|---|---|73
|---|---|---|---|---|---|@@ -94,48 +94,59 @@ Suggested layout: `character.ppd`, `assets/body/`, `assets/equipment/`, `presets94
94
95
Validate finite numeric values, dimensions > 0, bounded dimensions and memory estimate, positive FPS, valid clip references, existing model/preset paths and output directories distinct from sources. A missing optional sex slot is a valid project, but cannot render that slot. No absolute-path leakage into exported manifests unless user explicitly chooses external references.95
Validate finite numeric values, dimensions > 0, bounded dimensions and memory estimate, positive FPS, valid clip references, existing model/preset paths and output directories distinct from sources. A missing optional sex slot is a valid project, but cannot render that slot. No absolute-path leakage into exported manifests unless user explicitly chooses external references.96
96
97
### format_version 197
### Implemented format_version 2 (version 1 migration)98
98
99
Implemented project file: one UTF-8 JSON manifest per project directory, default name `character.ppd`. Fields are `format_version` (exactly 1), `id`, `name`, `male_model`, `female_model`, `equipment_presets`, `output_directory`, and `render` with `target_width`, `target_height` and `fps`. Sex slots are JSON null when empty. Paths use `/` and stay inside the project directory. A preset file stem is a lowercase slug and is not `body`. Unknown fields are rejected, so a rewrite cannot drop them. Any other `format_version` is rejected before the rest of the object is accepted. Save writes a temporary sibling in the same directory and renames it over the manifest only after the bytes are on disk. A failed save leaves the previous manifest in place. Naming a model file does not import or render it. Animation inventory and import transforms are not part of version 1.99
`character.ppd` is a strict UTF-8 JSON manifest. Fields remain `format_version`, `id`, `name`, nullable `male_model`/`female_model`, `equipment_presets`, `output_directory`, and `render` (`target_width`, `target_height`, `fps`). Version 2 adds `render.framing`, an array **[male, female]** of `{height_milli, center_y_milli}`: integer world-unit millimetres, default height 2600 and center Y 1000. Heights 100…10000 and centers −10000…10000 are validated. Each sex has its own fixed camera, shared by every body/equipment export. Opening version 1 migrates in memory with default framing; the next save writes version 2. Other versions and unknown fields are rejected, not silently discarded. This does not add source-axis/import-transform overrides or animation inventory overrides.100
101
Paths use `/` and stay inside the project directory. Save writes/fsyncs a temporary sibling and atomically replaces the manifest; failure preserves the prior manifest. Binary equipment snapshots remain separate. Per-sex framing is part of the normal unsaved-change/save/discard gate, not transient orbit state.100
102
101
## 6. Camera, axes, alignment and animation sampling103
## 6. Camera, axes, alignment and animation sampling102
104
103
Required views: pitches -30, 0, +30 degrees; yaw 0, 30, 60, ..., 330 degrees. **Proposal:** yaw zero means the standardized character front faces the camera, world +Y is up, and positive yaw is defined by a documented right-hand convention. Distinguish camera elevation from model rotation: use orbiting orthographic camera around fixed character origin. Golden orientation fixture must show labelled front/back/left/right to remove ambiguity before production export.105
Required views: pitches -30, 0, +30 degrees; yaw 0, 30, 60, ..., 330 degrees. **Proposal:** yaw zero means the standardized character front faces the camera, world +Y is up, and positive yaw is defined by a documented right-hand convention. Distinguish camera elevation from model rotation: use orbiting orthographic camera around fixed character origin. Golden orientation fixture must show labelled front/back/left/right to remove ambiguity before production export.104
106
105
Use orthographic projection for aligned layering. Shared camera framing, pivot, near/far, canvas size and unit normalization are stored per sex/export set and must match across body/equipment. Determine a stable envelope across selected animations, with configurable margin and equipment allowance. Never independently auto-frame each equipment piece or animation frame. Source-model forward-axis correction is explicit in project import settings.107
**Implemented fixed framing:** orthographic projection, +Y world up, yaw 0 camera on −Z looking toward the character; positive yaw follows the existing orbit convention. Orbit target is `(0, center_y_milli/1000, 0)`, camera distance **10**, vertical world extent `height_milli/1000`, horizontal extent vertical × target aspect. Near **0.05**, far **40** are shared absolutely across all layers, frames and views. No per-frame/per-equipment auto-framing or depth normalization. Source root motion is retained as imported (no extraction). User adjusts height/center in the body view to allow all animation/equipment extents; this is manual, not a promised automatic animation envelope.106
108
107
**Proposal:** sample looping clips at t=i/FPS for 0 <= i < ceil(duration*FPS), excluding a duplicate terminal sample; validate zero-duration clips and offer a bind/static frame instead. Store actual sample timestamps and duration in output metadata. Root motion is initially in-place with a documented extraction policy; preserve original clips and allow future explicit mode. No interpolation of ordinal frames as though every clip had the same duration.109
**Vast renderkader tonen** switches live placement to this same orthographic camera and target-aspect, centered yellow frame. Drag still changes yaw/pitch; orbit-scroll zoom is ignored while the fixed frame is shown. Uncheck it for the independent perspective orbit/scroll camera. Neither free orbit zoom nor live selected clip/time changes a production job. Render snapshots the current project settings at job start; later edits do not change that in-flight batch. **Opslaan** persists male and female fixed framing in `.ppd`.108
110
109
**Confirmed production Render behavior (next feature after equipment):** enumerate **all animation clips × all 36 angles × every frame sampled at project FPS**, processing one pose/view and its real passes/maps at a time. Do not stop at the currently selected preview clip, angle or frame. Deterministic proposed nesting is source clip order → pitch -30/0/+30 → yaw 0…330 → frame → pass/map order from §8. Replace the preview after each completed real pass, immediately and without added delays. This is a full production batch requirement; the currently shipped Render action is still explicitly a single shaded body still, not this batch.111
**Implemented batch nesting:** source clip order → sampled frame → pitch **−30,0,+30** → yaw **0…330 step30** → six completed passes. Samples are `t=frame/FPS`, `0 <= frame < max(1, ceil(duration*FPS))`. To avoid exporter float noise creating a near-duplicate terminal frame, the product snaps to its nearest integer when within `4*f32::EPSILON*max(1,product)` before ceiling; a regression covers an exported 1-second endpoint one ULP above 1. Otherwise the full fractional duration is sampled; a zero-duration clip has one static sample; no clips produces one bind-pose clip. Duplicate display names get indexed tokens and internally unique sampling names, not accidental reuse of the first clip. All clips/frames/views are rendered regardless of current live selection. FPS is sampling, not playback rate.110
112
111
The 8x requirement means **8x width AND 8x height**, hence 64x pixel count. FPS controls animation sampling rather than interactive preview performance. Render maps in a bounded streaming pipeline, not all frames in RAM. Estimate disk/GPU/memory cost before starting: frame count times 36 views times map count. Cancellation stops between safe work units and leaves an incomplete marker, never an apparently complete asset set.113
Highres is exactly **8× width AND height** (64× pixels); targets currently **1…256 per axis**, highres max **2048 per axis**. Larger projects may be opened but their batch render is explicitly rejected, never silently reduced to 1×. A batch is limited to 500000 PNGs and 16 GiB estimated uncompressed target data. Render streams one pose/view at a time; no whole-batch image allocation. GPU service is one offscreen draw/readback unit per native UI update, with real depth24 attachment/test/write/clear; skinning, detail/resolve and PNG writes run on a background worker. The GUI keeps updating; a large individual GPU unit can still take a frame. The latest-pass mailbox retains **one** completed image, one progress update, and a terminal event; no unbounded preview backlog. No sleeps, pass playback delay or preview acknowledgment. Fast passes can be visually skipped, never faked.112
114
113
## 7. Export naming and metadata115
## 7. Export naming and metadata114
116
115
Required schema: `<geslacht>-<settype>-<animatienaam>-<pitch>-<yaw>-<maptype>-f<framenummer>.png`.117
The current hierarchical layout supersedes the earlier flat filename proposal (the original brief is retained verbatim in the historical appendix):118
119
```text120
renders/run-<unix-nanoseconds>-<pid>/<male|female>/<body|equipment-slug>/121
clip<source-index:03>-<safe-name>/f<frame:06>/p<pitch:+03>_y<yaw:03>_<map>.png122
# example: .../male/body/clip001-walk/f000012/p-30_y330_diffuse.png123
```116
124
117
**Proposal:** sex tokens `male` and `female`; body set type exactly `body`; equipment name validated as a lowercase slug without spaces, reject reserved `body`. Use deterministic safe animation slugs plus collision registry preserving original names in a companion manifest. Decide delimiter escaping explicitly: negative pitch creates adjacent hyphens, so parse from the known suffix rather than naive splitting. Example `male-body-walk--30-0-diffuse-f000000.png` corresponds to pitch -30/yaw 0/frame 0. A six-digit frame width is a provisional minimum, not a cap; width and naming are stored in metadata.125
`output_directory` may change the `renders/` root. Safe lowercase clip slugs replace non-ASCII/non-alphanumerics with `-`, truncate to 48 characters, and prefix source index (so duplicate names cannot collide). Equipment slugs use the existing strict preset validator; `body` is reserved. Frame index is zero-based. Map tokens are **only `diffuse`, `depth`, `normal`, `specular`**, for body AND equipment, all exactly target width/height. **Highres and detail are preview-only intermediates: never PNG exports.** Each run is a fresh directory, never replaces any previous run or named image. Canonical parent checks reject traversal/outside symlinks; sibling temporary file + fsync + no-clobber hardlink publication prevents a partial PNG appearing as a valid final file. Filesystems must support hardlinks; unsupported filesystems fail explicitly. Existing or foreign temporary files are never deleted on creation failure.118
126
119
The companion output manifest stores original clip IDs/names, name tokens, timestamps, dimensions, FPS, camera matrices/conventions, shared depth range, normal space, alpha mode, specular convention, render settings/version, source fingerprints and completion status. This metadata supplements rather than replaces the required PNG filenames. Output writes use temporary files and explicit overwrite choice. Sanitize names without creating collisions or interpreting slashes as directories.127
`render.json` (output metadata schema 1, separate from `.ppd` schema 2) records sex/set, target, FPS, highres scale, framing, common near/far, conventions, source clip names/tokens/durations/frame counts, six-pass order, four saved maps and actual counts. Frame timestamps follow the recorded sampling equation. `complete.json` is published ONLY after all PNGs complete; cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json`. Source-file checksums, per-image checksums, resumable batches and automatic cleanup are future work, not implemented manifest fields.120
128
121
## 8. Map contracts and rendering algorithm129
## 8. Map contracts and rendering algorithm122
130
123
Diffuse is an **unlit base color** suitable for later lighting, not an already lit screenshot. Preserve alpha, use sRGB encoding only at output boundaries and process color in linear light. Transparency outside geometry remains zero; distinguish uncovered pixels from valid near-depth pixels.131
**Diffuse (Tim t953u): unlit albedo only.** Linear material base color × sRGB-decoded base-color texture × linear vertex COLOR_0 (white if absent), then sRGB encode at the PNG boundary. Preserve straight alpha/coverage. **No lighting, shading, shadows, specular or rim accents are baked in.** Live 3D may stay lit. A native GPU texture×vertex-color regression checks exact expected albedo/alpha and byte-identical diffuse under three opposed light directions.132
133
**Depth:** actual depth24 GPU attachment readback, resolved to **16-bit grayscale+alpha PNG**, same coverage as other maps; `distance = near + (gray/65535)*(far-near)`. Smaller covered depth is nearer. Transparent zero values are invalid, not a near plane. Fixed near/far and camera distance are identical for body/equipment and every frame. No luminance-derived pseudo-depth or per-frame rescaling.134
135
**Normal:** view-space skinned geometric/shading normal, RGB8 encodes `n*0.5+0.5`, straight coverage alpha. Camera +Y is up; PNG rows are top-down. Normals are transformed by inverse-transpose skin transforms and the common view rotation, then normalized after resolve. Transparent RGB is zero and must be ignored. Tangent-space source normal textures and full PBR material conversion are not implemented.124
136
125
Depth **proposal:** 16-bit grayscale PNG representing linear camera-space depth normalized to one shared near/far interval for all compatible layers. Smaller valid values mean nearer the viewer. Use diffuse alpha/coverage as validity; never interpret the transparent background as the closest surface. No per-frame or per-equipment depth rescaling. Record the exact decode equation in metadata. Depth ordering tests must include equipment in front of and behind different body regions.137
**Specular:** RGBA8, linear scalar repeated in RGB, shared coverage alpha; body defaults to zero. Equipment uses its editable strength × shinymap luminance (when present), not a baked highlight. Output token is `specular` (not the old provisional `shiny`). The future consumer chooses the lighting/exponent; this is not physically exact metallic/roughness PBR.126
138
127
Normals **proposal:** view-space geometric/shading normal, xyz encoded from [-1,1] to [0,1] in RGB PNG, with documented image Y convention; normal space must match the preview light transform. Normalize after interpolation/filtering. Uncovered pixels have alpha zero and a neutral placeholder normal. Include tangent-space source normal maps where supported and explicitly test their conversion; a geometry-normal bake is not automatically full source-material normal fidelity.139
Per pose/view, the actual completed-preview order is:140
1. **Highres:** evaluate skeleton/attachments at exact sample time; offscreen MRT draws unlit albedo, view normal, scalar specular and true depth at exact 8× dimensions. Publish highres albedo immediately, no file saved.141
2. **Detail:** color/alpha/depth/normal neighbor discontinuities, bounded weights. Publish actual edge map immediately, no file saved.142
3. **Target-res / diffuse:** for each 8×8 block compute coverage, choose nearest covered depth and a coherent front-surface cluster (depth tolerance 0.0025 of the common near/far interval), apply alpha × `(1+4*detail)` weights, and resolve albedo in linear light. Color/normal/specular all use that same cluster, coverage comes from all covered samples; this explicit front-feature retention policy is an approximation, not unbiased area filtering. Normalize normal. Write target diffuse, publish NN-upscaled preview.143
4. **Depth:** write target 16-bit gray+alpha, publish its gray8 visualization (the saved depth keeps 16-bit precision).144
5. **Normal:** write/publish target normal.145
6. **Specular:** write/publish target scalar map, including body zero.128
146
129
Equipment shinymap **proposal:** grayscale scalar specular strength (0..1), with a documented default exponent/roughness in metadata. Filename token `shiny` is provisional because Tim named the concept but did not mandate a token spelling. Metallic/roughness and Phong materials do not map uniquely to one scalar; expose a conservative conversion/default and retain editable overrides. Do not label it physically exact PBR. Body has diffuse/depth/normal; equipment additionally shiny.147
Every pass uses one uniform scale, centered letterbox, nearest-neighbor display; never stretch target X/Y independently. The progress bar counts actual completed passes out of `images*6`, and a separate label identifies the last completed image while a later pass works. Each pose saves exactly four target PNGs. **Annuleer render** stops between safe units (or after the in-flight GPU readback), keeps partial outputs and never produces a complete marker. Rim lighting and the 2D lighting/compositor remain deferred; any future rim belongs in the consumer, not in pure diffuse albedo.130
148
131
Pipeline per shared scene sample/view:149
Current source scope: the first skinned glTF/GLB mesh (all its triangle primitives), first skin, vertex NORMAL attributes when present, otherwise true flat normals from GPU derivatives of the skinned view-space triangle position (never the live-only fallback vector), base-color factors/textures and COLOR_0; albedo images must be 8-bit and ≤2048×2048. Textures currently use nearest/repeat; sampler overrides/texture transforms, morph targets, tangent normal maps, multi-mesh scenes and arbitrary translucent layer ordering are not claimed. Opaque/alpha-cutout uses one winning surface; this is not order-independent blended transparency. GPU limits also reject non-finite geometry, more than 1M vertices/3M indices per draw, invalid texture payloads and invalid camera matrices. FBX remains inventory-only.132
1. Evaluate skeleton and bone world transforms at the exact sample time.133
2. Render high-resolution unlit color/coverage and geometry buffers at 8x target dimensions. Depth/normals originate from the actual same surfaces, not image luminance.134
3. Compute an internal detail map using color gradients plus alpha/depth/normal discontinuities, bounded weights and transparent-neighbor handling. Detail map is intermediate, not a fourth body output map.135
4. Downsample diffuse with deterministic coverage-aware weighted reconstruction in linear premultiplied color; unpremultiply safely for straight-alpha PNG output. Tune thin-feature retention on fixtures, not on a named but unimplemented algorithm.136
5. Resolve target-resolution depth/normals from coherent contributing surfaces. Avoid mixing background with foreground or averaging front and back depths into nonphysical geometry. Align the chosen representative/coverage policy across maps. Renormalize normals.137
6. Add a subtle configurable rim/detail accent to diffuse based on normal/depth discontinuities, never a light-direction bake. Clamp magnitude, preserve alpha; default must be validated visually to avoid double-lighting later.138
7. Encode all target-resolution PNGs atomically and record checksums/metadata. Test deterministic CPU stages independently from GPU tolerance.139
150
140
Equipment pass hides the body from **all color, depth, normal and specular writes**, while still evaluating its rig and animating attachments. The body is not an invisible occluder; otherwise equipment behind it would be missing when layers are recomposed. Equipment self-occludes normally. Every layer must use the same projection and absolute depth contract. Hair/alpha cutout versus true blended transparency is an open fidelity decision; initial opaque/cutout support must be stated rather than promising arbitrary multilayer transparency from a single depth value.151
Equipment pass hides the body from **all color, depth, normal and specular writes**, while still evaluating its rig and animating attachments. The body is not an invisible occluder; otherwise equipment behind it would be missing when layers are recomposed. Equipment self-occludes normally. Every layer must use the same projection and absolute depth contract. Hair/alpha cutout versus true blended transparency is an open fidelity decision; initial opaque/cutout support must be stated rather than promising arbitrary multilayer transparency from a single depth value.141
152
@@ -175,13 +186,13 @@ Depth compositor selects the nearest valid surface per pixel from body and chose175
186
176
## 11. Development workflow, quality and packaging187
## 11. Development workflow, quality and packaging177
188
178
Each feature starts on its own `feature/...` branch from current master. Implement its tests and acceptance criteria, run local checks, commit, then `git merge --no-ff` into master and push. No initial-phase MRs. Keep branch/feature and merge SHAs in the coordinator status file, report completion before the next feature. Setup/design may use `feature/project-design`; establish a baseline master commit first if required to obtain a genuine two-parent merge. Never fabricate a merge on an unborn branch.189
Each feature starts on its own `feature/...` branch from current master. Implement its tests and acceptance criteria, run local checks, commit, then `git merge --no-ff` into master and push. No initial-phase MRs. Keep branch/feature and merge SHAs in the coordinator status file, report completion before the next feature. For this production-render task the latest explicit instruction overrides publication: merge/cleanup locally only, **no push, tags, releases or package builds**; the parent handles subsequent publication. Setup/design may use `feature/project-design`; establish a baseline master commit first if required to obtain a genuine two-parent merge. Never fabricate a merge on an unborn branch.179
190
180
After every merge, `README.md` describes only the current behavior: what the program can do and how to use it. It does not list absent features, a roadmap, or negative claims. A screenshot belongs in the README only when it shows how to use the program. This convention applies to every later feature. Design limits stay in this file.191
After every merge, `README.md` describes only the current behavior: what the program can do and how to use it. It does not list absent features, a roadmap, or negative claims. A screenshot belongs in the README only when it shows how to use the program. This convention applies to every later feature. Design limits stay in this file.181
192
182
The application icon is artwork supplied by Tim Falken. The original file is `assets/icons/paperdoll-renderer.png` (57×60 RGBA). Package bitmaps are scaled from that file with a premultiplied Lanczos filter, so the drawing is not redrawn. The eframe window uses the 128×128 raster as its window icon. Larger hicolor sizes stay in the packages. Linux AppImage, `.deb` and Arch packages install the hicolor sizes and name the icon `paperdoll-renderer` from both the desktop file and the AppStream metainfo. The MinGW Windows build embeds the same artwork in `paperdoll-renderer.exe` as `RT_GROUP_ICON`; the zip does not carry a separate `.ico`.193
The application icon is artwork supplied by Tim Falken. The original file is `assets/icons/paperdoll-renderer.png` (57×60 RGBA). Package bitmaps are scaled from that file with a premultiplied Lanczos filter, so the drawing is not redrawn. The eframe window uses the 128×128 raster as its window icon. Larger hicolor sizes stay in the packages. Linux AppImage, `.deb` and Arch packages install the hicolor sizes and name the icon `paperdoll-renderer` from both the desktop file and the AppStream metainfo. The MinGW Windows build embeds the same artwork in `paperdoll-renderer.exe` as `RT_GROUP_ICON`; the zip does not carry a separate `.ico`.183
194
184
Live 3D preview, as built: the body screen of the eframe window draws the skinned glTF/GLB mesh with a perspective orbit camera. Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. It is not the orthographic paperdoll export, and it does not draw OBJ or FBX. The shell explicitly requests a native 24-bit depth buffer in eframe's **Glow/OpenGL** backend (not wgpu). Every body callback owns depth-test LESS, depth writes and clear-to-1 each frame; subsequent equipment layers must share that attachment without clearing it. A real GPU overlap/readback probe checks draw-order independence and depth clearing between frames. The CPU still rasterizer also depth-tests finite in-frustum fragments; neither preview is allowed to use painter's submission order as occlusion.195
Live 3D preview, as built: the body screen draws the skinned glTF/GLB mesh with an independent perspective orbit camera or the visible fixed orthographic render frame (§6). Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. The placement preview is lit, unlike exported albedo. It overlays bone-attached OBJ equipment (§9); FBX is not drawn. The shell explicitly requests a native 24-bit depth buffer in eframe's **Glow/OpenGL** backend (not wgpu). Every body callback owns depth-test LESS, depth writes and clear-to-1 each frame; subsequent equipment layers must share that attachment without clearing it. A real GPU overlap/readback probe checks draw-order independence and depth clearing between frames. The CPU still rasterizer also depth-tests finite in-frustum fragments; neither preview is allowed to use painter's submission order as occlusion.185
196
186
CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.197
CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.187
198
@@ -204,7 +215,7 @@ These requirements are durable even where the implementation is staged. They sup204
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.215
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.205
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.216
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.206
217
207
Current implementation checkpoint: startup/native pickers, seeded projects, body-centric navigation, equipment name/model CRUD, unsaved-change gate, CLI project opening, platform integration assets, icon and live animated glTF preview are implemented. Stage 4 equipment authoring is implemented as described in §9, with binary embedded OBJ presets and actual animated bone-attached placement preview; legacy JSON records remain readable. The current **Render** action exports a shaded color still of the selected glTF pose/camera through high-resolution rasterization and target-resolution nearest sampling, with two real pass previews and progress. It does not claim production sprite exports: full 36-view/animation batching, orthographic framing, detail-aware reconstruction and diffuse/depth/normal/shiny/rim maps remain stages 5–6 above. The complete requested preview order is preserved here and in `PASS_ORDER` for that work; unimplemented passes are excluded from the running job’s progress. High-resolution stills use up to 8× dimensions within a 1024-pixel high-resolution staging limit for small targets; larger valid targets use scale 1. This is a preview/still renderer, not the production contract in §8.218
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview and fixed per-sex framing are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The 2D relighting/compositor, rim effects, resume and FBX drawing remain unimplemented.208
219
209
## 12. Incremental feature plan and acceptance gates220
## 12. Incremental feature plan and acceptance gates210
221
@@ -291,7 +302,7 @@ Minimal server change, not applied in this repository: add `POST /api/v1/repos/{291
302
292
Each file has one skin, 19 named joints, rigid skin weights, and two clips: `walk` (1 second, arms opposite the legs) and `attack` (0.8 seconds, the right arm swings forward). Bind rotations are identity. The documented axes are +Y up and forward -Z. Male and female differ in shoulder width, hip width and hip height so a later importer can tell them apart.303
Each file has one skin, 19 named joints, rigid skin weights, and two clips: `walk` (1 second, arms opposite the legs) and `attack` (0.8 seconds, the right arm swings forward). Bind rotations are identity. The documented axes are +Y up and forward -Z. Male and female differ in shoulder width, hip width and hip height so a later importer can tell them apart.293
304
294
`tests/test_body_assets.rs` checks that both files exist, parse as GLB, contain that skeleton, contain both clips, and byte-match a fresh run of the generator. That test is a fixture check. It is not the product importer, it does not play the clips, and it does not render. The desktop shell still reports glTF/GLB as not built and does not open these files.305
`tests/test_body_assets.rs` checks that both files exist, parse as GLB, contain that skeleton, contain both clips, and byte-match a fresh run of the generator. That test is a fixture check. It is not the product importer, it does not play the clips, and it does not render. The current editor imports and animates these CC0 fixtures; native GPU and batch probes test actual rendering separately from this asset-structure test.295
306
296
# Appendix A — Tim's original proposal (verbatim)307
# Appendix A — Tim's original proposal (verbatim)297
308
@@ -23,6 +23,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
23
23
.ok_or_else(|| "geen Glow-context".to_string())
24
24
.and_then(|gl| unsafe {
25
25
paperdoll_renderer::preview_gl::depth_regression_probe(gl)
26
.and_then(|_| paperdoll_renderer::production::map_regression_probe(gl))
26
27
});
27
28
*result.lock().unwrap() = Some(test);
28
29
Ok(Box::new(Quit))
@@ -23,6 +23,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {23
.ok_or_else(|| "geen Glow-context".to_string())23
.ok_or_else(|| "geen Glow-context".to_string())24
.and_then(|gl| unsafe {24
.and_then(|gl| unsafe {25
paperdoll_renderer::preview_gl::depth_regression_probe(gl)25
paperdoll_renderer::preview_gl::depth_regression_probe(gl)26
.and_then(|_| paperdoll_renderer::production::map_regression_probe(gl))26
});27
});27
*result.lock().unwrap() = Some(test);28
*result.lock().unwrap() = Some(test);28
Ok(Box::new(Quit))29
Ok(Box::new(Quit))@@ -0,0 +1,128 @@
1
//! Actual native end-to-end batch smoke; no GUI input automation or preview delays.
2
//! Usage: production_fixture ROOT MALE.glb FEMALE.glb male|female WIDTH HEIGHT FPS [equipment] [--height-milli=N]
3
use eframe::App;
4
use paperdoll_renderer::{
5
editor::EditorApp,
6
equipment, preview,
7
session::{adopt_file, Session, Sex},
8
};
9
use std::path::PathBuf;
10
struct Smoke {
11
editor: EditorApp,
12
root: PathBuf,
13
last: u32,
14
frames: u64,
15
done: bool,
16
}
17
impl App for Smoke {
18
fn update(&mut self, ctx: &eframe::egui::Context, frame: &mut eframe::Frame) {
19
self.frames += 1;
20
self.editor.update(ctx, frame);
21
let (completed, total, label) = self.editor.render_progress();
22
if completed != self.last {
23
println!("PASS {completed}/{total}: {label}");
24
self.last = completed;
25
}
26
if !self.editor.render_is_running() && !self.done {
27
self.done = true;
28
let error = self.editor.render_error();
29
let success = error.is_none() && total > 0 && completed == total;
30
let value = serde_json::json!({"success":success,"completed":completed,"total":total,"last_pass":label,"gui_updates":self.frames,"error":error});
31
std::fs::write(
32
self.root.join("smoke-result.json"),
33
serde_json::to_vec_pretty(&value).unwrap(),
34
)
35
.unwrap();
36
println!("RESULT {value}");
37
ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);
38
}
39
}
40
fn on_exit(&mut self, gl: Option<&glow::Context>) {
41
self.editor.on_exit(gl);
42
}
43
}
44
fn main() -> Result<(), Box<dyn std::error::Error>> {
45
let a = std::env::args().skip(1).collect::<Vec<_>>();
46
if a.len() < 7 {
47
return Err(
48
"ROOT MALE FEMALE male|female WIDTH HEIGHT FPS [equipment] [--height-milli=N]".into(),
49
);
50
}
51
let root = PathBuf::from(&a[0]);
52
let mut s = Session::new();
53
s.install_new_project(&root)?;
54
for (sex, index) in [(Sex::Male, 1), (Sex::Female, 2)] {
55
let path = adopt_file(&root, &PathBuf::from(&a[index]), "assets/body")?;
56
s.set_body_path(sex, path);
57
}
58
s.set_render(a[4].parse()?, a[5].parse()?, a[6].parse()?);
59
let sex = match a[3].as_str() {
60
"male" => Sex::Male,
61
"female" => Sex::Female,
62
_ => return Err("invalid sex".into()),
63
};
64
let equipment = a.get(7).is_some_and(|v| v == "equipment");
65
if let Some(value) = a.iter().find_map(|v| v.strip_prefix("--height-milli=")) {
66
s.opened.as_mut().unwrap().document.render.framing[usize::from(sex == Sex::Female)]
67
.height_milli = value.parse()?;
68
}
69
70
if equipment {
71
let assets = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment");
72
let mut p = equipment::Preset::new("sword".into());
73
p.provenance = "CC0 generated fixture".into();
74
equipment::import_obj(&mut p, &assets.join("sword.obj"))?;
75
equipment::import_shiny(&mut p, 0, &assets.join("shiny.png"))?;
76
for (i, sex) in [(0, Sex::Male), (1, Sex::Female)] {
77
let b = preview::load_gltf_file(&root.join(s.body_path(sex).unwrap()))?;
78
let joint = b
79
.joint_names
80
.iter()
81
.position(|n| n == "RightHand")
82
.ok_or("RightHand absent")?;
83
p.bind(i, &b, 0, joint)?;
84
}
85
let i = s.add_equipment();
86
s.equipment[i].slug = "sword".into();
87
s.equipment[i].preset = Some(p);
88
}
89
s.save()?;
90
for sex in [Sex::Male, Sex::Female] {
91
let b = preview::load_gltf_file(&root.join(s.body_path(sex).unwrap()))?;
92
println!(
93
"PLAN {sex:?}: {:?}",
94
paperdoll_renderer::production::plan(&b, a[6].parse()?)?
95
);
96
}
97
let root2 = root.clone();
98
eframe::run_native(
99
"Paperdoll Production Render",
100
eframe::NativeOptions {
101
viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1150., 820.]),
102
..paperdoll_renderer::preview_gl::native_options()
103
},
104
Box::new(move |_| {
105
let mut editor = EditorApp::default();
106
editor.open_path(root2.join("character.ppd"));
107
if equipment {
108
editor.open_equipment_configuration(0, sex);
109
} else {
110
editor.open_body_configuration(sex);
111
}
112
editor.render_all_angles();
113
Ok(Box::new(Smoke {
114
editor,
115
root: root2,
116
last: 0,
117
frames: 0,
118
done: false,
119
}))
120
}),
121
)?;
122
let report: serde_json::Value =
123
serde_json::from_slice(&std::fs::read(root.join("smoke-result.json"))?)?;
124
if report["success"] != true {
125
return Err("production smoke failed; see smoke-result.json".into());
126
}
127
Ok(())
128
}
@@ -0,0 +1,128 @@1
//! Actual native end-to-end batch smoke; no GUI input automation or preview delays.2
//! Usage: production_fixture ROOT MALE.glb FEMALE.glb male|female WIDTH HEIGHT FPS [equipment] [--height-milli=N]3
use eframe::App;4
use paperdoll_renderer::{5
editor::EditorApp,6
equipment, preview,7
session::{adopt_file, Session, Sex},8
};9
use std::path::PathBuf;10
struct Smoke {11
editor: EditorApp,12
root: PathBuf,13
last: u32,14
frames: u64,15
done: bool,16
}17
impl App for Smoke {18
fn update(&mut self, ctx: &eframe::egui::Context, frame: &mut eframe::Frame) {19
self.frames += 1;20
self.editor.update(ctx, frame);21
let (completed, total, label) = self.editor.render_progress();22
if completed != self.last {23
println!("PASS {completed}/{total}: {label}");24
self.last = completed;25
}26
if !self.editor.render_is_running() && !self.done {27
self.done = true;28
let error = self.editor.render_error();29
let success = error.is_none() && total > 0 && completed == total;30
let value = serde_json::json!({"success":success,"completed":completed,"total":total,"last_pass":label,"gui_updates":self.frames,"error":error});31
std::fs::write(32
self.root.join("smoke-result.json"),33
serde_json::to_vec_pretty(&value).unwrap(),34
)35
.unwrap();36
println!("RESULT {value}");37
ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);38
}39
}40
fn on_exit(&mut self, gl: Option<&glow::Context>) {41
self.editor.on_exit(gl);42
}43
}44
fn main() -> Result<(), Box<dyn std::error::Error>> {45
let a = std::env::args().skip(1).collect::<Vec<_>>();46
if a.len() < 7 {47
return Err(48
"ROOT MALE FEMALE male|female WIDTH HEIGHT FPS [equipment] [--height-milli=N]".into(),49
);50
}51
let root = PathBuf::from(&a[0]);52
let mut s = Session::new();53
s.install_new_project(&root)?;54
for (sex, index) in [(Sex::Male, 1), (Sex::Female, 2)] {55
let path = adopt_file(&root, &PathBuf::from(&a[index]), "assets/body")?;56
s.set_body_path(sex, path);57
}58
s.set_render(a[4].parse()?, a[5].parse()?, a[6].parse()?);59
let sex = match a[3].as_str() {60
"male" => Sex::Male,61
"female" => Sex::Female,62
_ => return Err("invalid sex".into()),63
};64
let equipment = a.get(7).is_some_and(|v| v == "equipment");65
if let Some(value) = a.iter().find_map(|v| v.strip_prefix("--height-milli=")) {66
s.opened.as_mut().unwrap().document.render.framing[usize::from(sex == Sex::Female)]67
.height_milli = value.parse()?;68
}69
70
if equipment {71
let assets = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment");72
let mut p = equipment::Preset::new("sword".into());73
p.provenance = "CC0 generated fixture".into();74
equipment::import_obj(&mut p, &assets.join("sword.obj"))?;75
equipment::import_shiny(&mut p, 0, &assets.join("shiny.png"))?;76
for (i, sex) in [(0, Sex::Male), (1, Sex::Female)] {77
let b = preview::load_gltf_file(&root.join(s.body_path(sex).unwrap()))?;78
let joint = b79
.joint_names80
.iter()81
.position(|n| n == "RightHand")82
.ok_or("RightHand absent")?;83
p.bind(i, &b, 0, joint)?;84
}85
let i = s.add_equipment();86
s.equipment[i].slug = "sword".into();87
s.equipment[i].preset = Some(p);88
}89
s.save()?;90
for sex in [Sex::Male, Sex::Female] {91
let b = preview::load_gltf_file(&root.join(s.body_path(sex).unwrap()))?;92
println!(93
"PLAN {sex:?}: {:?}",94
paperdoll_renderer::production::plan(&b, a[6].parse()?)?95
);96
}97
let root2 = root.clone();98
eframe::run_native(99
"Paperdoll Production Render",100
eframe::NativeOptions {101
viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1150., 820.]),102
..paperdoll_renderer::preview_gl::native_options()103
},104
Box::new(move |_| {105
let mut editor = EditorApp::default();106
editor.open_path(root2.join("character.ppd"));107
if equipment {108
editor.open_equipment_configuration(0, sex);109
} else {110
editor.open_body_configuration(sex);111
}112
editor.render_all_angles();113
Ok(Box::new(Smoke {114
editor,115
root: root2,116
last: 0,117
frames: 0,118
done: false,119
}))120
}),121
)?;122
let report: serde_json::Value =123
serde_json::from_slice(&std::fs::read(root.join("smoke-result.json"))?)?;124
if report["success"] != true {125
return Err("production smoke failed; see smoke-result.json".into());126
}127
Ok(())128
}@@ -1,7 +1,6 @@
1
1
//! Project window: start screen, body, equipment, and the render queue.
2
2
3
3
use std::path::PathBuf;
4
use std::sync::mpsc::Receiver;
5
4
6
5
use eframe::egui;
7
6
@@ -21,8 +20,12 @@ pub struct EditorApp {
21
20
preview_female: Option<PreviewCharacter>,
22
21
viewport: Viewport,
23
22
inventories: [Option<Result<ImportedModel, String>>; 2],
24
render_rx: Option<Receiver<RenderEvent>>,
23
render_worker: Option<crate::production::RenderThread>,
24
gpu_renderer: crate::production::GpuRenderer,
25
show_render_frame: bool,
25
26
render_ui: RenderUiState,
27
pass_texture: Option<egui::TextureHandle>,
28
pass_texture_revision: u64,
26
29
applied_shell: Option<[f32; 2]>,
27
30
width_text: String,
28
31
height_text: String,
@@ -42,8 +45,12 @@ impl EditorApp {
42
45
preview_female: None,
43
46
viewport: Viewport::new(),
44
47
inventories: [None, None],
45
render_rx: None,
48
render_worker: None,
49
gpu_renderer: Default::default(),
50
show_render_frame: true,
46
51
render_ui: RenderUiState::idle(),
52
pass_texture: None,
53
pass_texture_revision: 0,
47
54
applied_shell: None,
48
55
width_text: "128".to_string(),
49
56
height_text: "128".to_string(),
@@ -101,8 +108,9 @@ impl EditorApp {
101
108
}
102
109
103
110
fn reset_render(&mut self) {
104
self.render_rx = None;
111
self.render_worker = None;
105
112
self.render_ui = RenderUiState::idle();
113
self.pass_texture = None;
106
114
}
107
115
108
116
fn sync_fields(&mut self) {
@@ -345,27 +353,50 @@ impl EditorApp {
345
353
self.session.set_render(width, height, fps);
346
354
}
347
355
348
/// Start the same worker used by the Render button (also used by the GUI smoke fixture).
356
/// Open the body editor, also used by the native smoke example.
357
pub fn open_body_configuration(&mut self, sex: Sex) {
358
self.session.show_body(sex);
359
self.controls.sex = match sex {
360
Sex::Male => PreviewSex::Male,
361
Sex::Female => PreviewSex::Female,
362
};
363
}
364
pub fn render_error(&self) -> Option<&str> {
365
self.render_ui.error.as_deref()
366
}
367
pub fn render_all_angles(&mut self) {
368
self.start_render();
369
}
370
/// Legacy smoke entry point, now starts the production batch as well.
349
371
pub fn render_current_pose(&mut self) {
350
self.start_render([480, 640]);
372
self.render_all_angles();
351
373
}
352
353
fn start_render(&mut self, preview: [u32; 2]) {
354
if let Some(opened) = &self.session.opened {
355
if let Err(error) = opened.document.validate_shape() {
356
self.session.fail(error.to_string());
357
return;
358
}
374
pub fn render_is_running(&self) -> bool {
375
self.render_ui.running
376
}
377
pub fn render_progress(&self) -> (u32, u32, String) {
378
(
379
self.render_ui.progress.completed,
380
self.render_ui.progress.total,
381
self.render_ui.progress.label.clone(),
382
)
383
}
384
fn start_render(&mut self) {
385
if self.render_ui.running {
386
return;
359
387
}
360
let Some(character) = self.character().cloned() else {
361
self.session
362
.fail("dit lichaam heeft geen glTF dat de preview kan tekenen");
388
let Some(opened) = &self.session.opened else {
363
389
return;
364
390
};
365
let Some(root) = self.session.project_root() else {
391
if let Err(error) = opened.document.validate_shape() {
392
self.session.fail(error.to_string());
393
return;
394
}
395
let Some(character) = self.character().cloned() else {
396
self.session.fail("dit lichaam heeft geen tekenbaar glTF");
366
397
return;
367
398
};
368
let Some(opened) = &self.session.opened else {
399
let Some(root) = self.session.project_root() else {
369
400
return;
370
401
};
371
402
if let Err(error) =
@@ -374,60 +405,145 @@ impl EditorApp {
374
405
self.session.fail(error.to_string());
375
406
return;
376
407
}
377
let sex = match self.controls.sex {
378
PreviewSex::Male => "male",
379
PreviewSex::Female => "female",
408
let sex = usize::from(self.controls.sex == PreviewSex::Female);
409
let preset = match self.session.screen {
410
Screen::Equipment(i) => self.session.equipment.get(i).and_then(|i| i.preset.clone()),
411
_ => None,
380
412
};
381
let output = root
382
.join(&opened.document.output_directory)
383
.join(format!("body-{sex}-still.png"));
413
if matches!(self.session.screen, Screen::Equipment(_)) && preset.is_none() {
414
self.session.fail("configureer eerst het equipment");
415
return;
416
}
384
417
let job = RenderJob {
385
418
character,
386
controls: self.controls.clone(),
387
target_width: opened.document.render.target_width.max(1),
388
target_height: opened.document.render.target_height.max(1),
389
preview_width: preview[0].max(1),
390
preview_height: preview[1].max(1),
391
output,
419
sex: if sex == 0 { "male" } else { "female" }.into(),
420
preset,
421
target_width: opened.document.render.target_width,
422
target_height: opened.document.render.target_height,
423
fps: opened.document.render.fps,
424
framing: opened.document.render.framing[sex],
425
output: root.join(&opened.document.output_directory),
392
426
};
393
let thread = spawn(job);
394
self.render_rx = Some(thread.rx);
427
self.render_worker = Some(spawn(job, None));
395
428
self.render_ui = RenderUiState::idle();
429
self.pass_texture = None;
396
430
self.render_ui.running = true;
397
let _ = thread.handle;
398
431
}
399
432
fn service_gpu(&mut self, gl: Option<&std::sync::Arc<glow::Context>>) {
433
let request = self
434
.render_worker
435
.as_ref()
436
.and_then(|worker| worker.gpu.try_recv().ok());
437
if let Some(request) = request {
438
if let Some(gl) = gl {
439
unsafe {
440
request.service(&mut self.gpu_renderer, gl);
441
}
442
} else {
443
request.fail("Glow-context ontbreekt".into());
444
}
445
}
446
}
400
447
fn drain_render(&mut self) {
401
let Some(rx) = &self.render_rx else {
448
let Some(worker) = &self.render_worker else {
402
449
return;
403
450
};
404
loop {
405
let event = match rx.try_recv() {
406
Ok(event) => event,
407
Err(std::sync::mpsc::TryRecvError::Empty) => break,
408
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
409
let message = "De renderworker stopte zonder eindresultaat.".to_string();
410
self.session.fail(message.clone());
411
self.render_ui.apply(RenderEvent::Failed(message));
412
self.render_rx = None;
413
break;
414
}
415
};
416
let finished = matches!(event, RenderEvent::Finished { .. } | RenderEvent::Failed(_));
451
let ended = worker
452
.handle
453
.as_ref()
454
.map(|h| h.is_finished())
455
.unwrap_or(true);
456
let events = worker.mailbox.drain();
457
let mut terminal = false;
458
for event in events {
459
terminal |= matches!(
460
event,
461
RenderEvent::Finished { .. } | RenderEvent::Failed(_) | RenderEvent::Cancelled
462
);
417
463
if let RenderEvent::Finished { saved: Some(path) } = &event {
418
self.session.notice = format!("Standbeeld opgeslagen: {path}");
464
self.session.notice = format!("Batch opgeslagen: {path}");
419
465
self.session.notice_ok = true;
420
466
}
421
if let RenderEvent::Failed(message) = &event {
422
self.session.fail(message.clone());
467
if let RenderEvent::Failed(error) = &event {
468
self.session.fail(error.clone());
423
469
}
424
470
self.render_ui.apply(event);
425
if finished {
426
self.render_rx = None;
427
break;
428
}
471
}
472
if terminal {
473
self.render_worker = None;
474
} else if ended {
475
self.render_ui.apply(RenderEvent::Failed(
476
"De renderworker stopte zonder eindresultaat.".into(),
477
));
478
self.render_worker = None;
479
}
480
}
481
fn cancel_render(&mut self) {
482
if let Some(worker) = &self.render_worker {
483
worker
484
.cancel
485
.store(true, std::sync::atomic::Ordering::Relaxed);
486
}
487
}
488
fn render_progress_ui(&mut self, ui: &mut egui::Ui) {
489
if self.render_ui.progress.total > 0 {
490
ui.add(
491
egui::ProgressBar::new(
492
self.render_ui.progress.completed as f32
493
/ self.render_ui.progress.total.max(1) as f32,
494
)
495
.text(format!(
496
"{} / {} · {}",
497
self.render_ui.progress.completed,
498
self.render_ui.progress.total,
499
self.render_ui.progress.label
500
)),
501
);
502
}
503
if self.render_ui.running && ui.button("Annuleer render").clicked() {
504
self.cancel_render();
429
505
}
430
506
}
507
fn framing_ui(&mut self, ui: &mut egui::Ui, sex: usize) {
508
if let Some(opened) = &mut self.session.opened {
509
let camera = &mut opened.document.render.framing[sex];
510
let mut height = camera.height_milli as f32 / 1000.;
511
let mut center = camera.center_y_milli as f32 / 1000.;
512
ui.horizontal_wrapped(|ui| {
513
ui.label("Vaste renderhoogte (m)");
514
if ui
515
.add(
516
egui::DragValue::new(&mut height)
517
.speed(0.01)
518
.range(0.1..=10.),
519
)
520
.changed()
521
{
522
camera.height_milli = (height * 1000.).round() as u32;
523
}
524
ui.label("Centrum Y (m)");
525
if ui
526
.add(
527
egui::DragValue::new(&mut center)
528
.speed(0.01)
529
.range(-10.0..=10.0),
530
)
531
.changed()
532
{
533
camera.center_y_milli = (center * 1000.).round() as i32;
534
}
535
ui.checkbox(&mut self.show_render_frame, "Vast renderkader tonen");
536
});
537
}
538
}
539
fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> {
540
if !self.show_render_frame {
541
return None;
542
}
543
let r = &self.session.opened.as_ref()?.document.render;
544
let sex = usize::from(self.controls.sex == PreviewSex::Female);
545
Some((r.framing[sex], [r.target_width, r.target_height]))
546
}
431
547
}
432
548
433
549
impl Default for EditorApp {
@@ -438,11 +554,16 @@ impl Default for EditorApp {
438
554
439
555
impl eframe::App for EditorApp {
440
556
fn on_exit(&mut self, gl: Option<&glow::Context>) {
557
self.render_worker = None;
441
558
if let Some(gl) = gl {
559
unsafe {
560
self.gpu_renderer.destroy(gl);
561
}
442
562
self.viewport.destroy(gl);
443
563
}
444
564
}
445
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
565
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
566
self.service_gpu(frame.gl());
446
567
let shell = self.session.shell_size();
447
568
if self.applied_shell != Some(shell) {
448
569
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
@@ -645,34 +766,31 @@ impl EditorApp {
645
766
self.session.show_equipment_list();
646
767
}
647
768
if ui
648
.add_enabled(!self.render_ui.running, egui::Button::new("Render"))
769
.add_enabled(
770
!self.render_ui.running,
771
egui::Button::new("Render alle hoeken"),
772
)
649
773
.clicked()
650
774
{
651
self.start_render([480, 640]);
775
self.start_render();
652
776
}
653
777
if ui.button("Live 3D").clicked() {
654
778
self.render_ui.preview = None;
655
779
}
656
780
});
657
if self.render_ui.progress.total > 0 {
658
let fraction = self.render_ui.progress.completed as f32
659
/ self.render_ui.progress.total.max(1) as f32;
660
ui.add(egui::ProgressBar::new(fraction).text(format!(
661
"{} / {} {}",
662
self.render_ui.progress.completed,
663
self.render_ui.progress.total,
664
self.render_ui.progress.label
665
)));
666
}
667
if let Some(image) = &self.render_ui.preview {
668
self.paint_pass(ui, image);
781
self.framing_ui(ui, usize::from(sex == Sex::Female));
782
self.render_progress_ui(ui);
783
if self.render_ui.preview.is_some() {
784
self.paint_pass(ui);
669
785
} else {
670
786
let character = match self.controls.sex {
671
787
PreviewSex::Male => self.preview_male.as_ref(),
672
788
PreviewSex::Female => self.preview_female.as_ref(),
673
789
};
674
790
if character.is_some() {
675
self.viewport.show(ui, &mut self.controls, character, &[]);
791
let framing = self.fixed_camera();
792
self.viewport
793
.show_framed(ui, &mut self.controls, character, &[], framing);
676
794
} else {
677
795
ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");
678
796
}
@@ -680,21 +798,30 @@ impl EditorApp {
680
798
});
681
799
}
682
800
683
fn paint_pass(&self, ui: &mut egui::Ui, image: &crate::render_job::ImageBuf) {
684
let color = egui::ColorImage::from_rgba_unmultiplied(
685
[image.width as usize, image.height as usize],
686
&image.rgba,
687
);
688
let texture = ui.ctx().load_texture(
689
"render-pass",
690
color,
691
egui::TextureOptions {
692
magnification: egui::TextureFilter::Nearest,
693
minification: egui::TextureFilter::Nearest,
694
wrap_mode: egui::TextureWrapMode::ClampToEdge,
695
..Default::default()
696
},
697
);
801
fn paint_pass(&mut self, ui: &mut egui::Ui) {
802
let Some(image) = &self.render_ui.preview else {
803
return;
804
};
805
ui.label(format!(
806
"Laatste voltooide pass: {}",
807
self.render_ui.preview_label
808
));
809
if self.pass_texture.is_none()
810
|| self.pass_texture_revision != self.render_ui.preview_revision
811
{
812
let color = egui::ColorImage::from_rgba_unmultiplied(
813
[image.width as usize, image.height as usize],
814
&image.rgba,
815
);
816
let options = egui::TextureOptions::NEAREST;
817
if let Some(texture) = &mut self.pass_texture {
818
texture.set(color, options);
819
} else {
820
self.pass_texture = Some(ui.ctx().load_texture("render-pass", color, options));
821
}
822
self.pass_texture_revision = self.render_ui.preview_revision;
823
}
824
let texture = self.pass_texture.as_ref().expect("pass texture installed");
698
825
let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
699
826
let fit = crate::render_job::fit_preview(
700
827
[image.width, image.height],
@@ -949,6 +1076,26 @@ impl EditorApp {
949
1076
if self.session.confirm.is_some() || self.dialog_pending {
950
1077
ui.disable();
951
1078
}
1079
ui.horizontal_wrapped(|ui| {
1080
if ui
1081
.add_enabled(
1082
!self.render_ui.running,
1083
egui::Button::new("Render equipment · alle hoeken"),
1084
)
1085
.clicked()
1086
{
1087
self.start_render();
1088
}
1089
if ui.button("Live 3D").clicked() {
1090
self.render_ui.preview = None;
1091
}
1092
});
1093
self.render_progress_ui(ui);
1094
if self.render_ui.preview.is_some() {
1095
self.paint_pass(ui);
1096
return;
1097
}
1098
let framing = self.fixed_camera();
952
1099
let body = if sex == 0 {
953
1100
self.preview_male.as_ref()
954
1101
} else {
@@ -960,7 +1107,8 @@ impl EditorApp {
960
1107
.get(index)
961
1108
.and_then(|item| item.preset.as_ref());
962
1109
let presets: Vec<_> = preset.into_iter().collect();
963
self.viewport.show(ui, &mut self.controls, body, &presets);
1110
self.viewport
1111
.show_framed(ui, &mut self.controls, body, &presets, framing);
964
1112
});
965
1113
}
966
1114
@@ -1136,10 +1284,14 @@ mod tests {
1136
1284
#[test]
1137
1285
fn renderer_disconnect_is_reported() {
1138
1286
let mut app = EditorApp::default();
1139
let (tx, rx) = std::sync::mpsc::channel();
1140
app.render_rx = Some(rx);
1287
let (_, gpu) = std::sync::mpsc::channel();
1288
app.render_worker = Some(crate::production::RenderThread {
1289
mailbox: Default::default(),
1290
gpu,
1291
cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1292
handle: None,
1293
});
1141
1294
app.render_ui.running = true;
1142
drop(tx);
1143
1295
app.drain_render();
1144
1296
assert!(!app.render_ui.running);
1145
1297
assert!(app.render_ui.error.is_some());
@@ -1,7 +1,6 @@1
//! Project window: start screen, body, equipment, and the render queue.1
//! Project window: start screen, body, equipment, and the render queue.2
2
3
use std::path::PathBuf;3
use std::path::PathBuf;4
use std::sync::mpsc::Receiver;5
4
6
use eframe::egui;5
use eframe::egui;7
6
@@ -21,8 +20,12 @@ pub struct EditorApp {21
preview_female: Option<PreviewCharacter>,20
preview_female: Option<PreviewCharacter>,22
viewport: Viewport,21
viewport: Viewport,23
inventories: [Option<Result<ImportedModel, String>>; 2],22
inventories: [Option<Result<ImportedModel, String>>; 2],24
render_rx: Option<Receiver<RenderEvent>>,23
render_worker: Option<crate::production::RenderThread>,24
gpu_renderer: crate::production::GpuRenderer,25
show_render_frame: bool,25
render_ui: RenderUiState,26
render_ui: RenderUiState,27
pass_texture: Option<egui::TextureHandle>,28
pass_texture_revision: u64,26
applied_shell: Option<[f32; 2]>,29
applied_shell: Option<[f32; 2]>,27
width_text: String,30
width_text: String,28
height_text: String,31
height_text: String,@@ -42,8 +45,12 @@ impl EditorApp {42
preview_female: None,45
preview_female: None,43
viewport: Viewport::new(),46
viewport: Viewport::new(),44
inventories: [None, None],47
inventories: [None, None],45
render_rx: None,48
render_worker: None,49
gpu_renderer: Default::default(),50
show_render_frame: true,46
render_ui: RenderUiState::idle(),51
render_ui: RenderUiState::idle(),52
pass_texture: None,53
pass_texture_revision: 0,47
applied_shell: None,54
applied_shell: None,48
width_text: "128".to_string(),55
width_text: "128".to_string(),49
height_text: "128".to_string(),56
height_text: "128".to_string(),@@ -101,8 +108,9 @@ impl EditorApp {101
}108
}102
109
103
fn reset_render(&mut self) {110
fn reset_render(&mut self) {104
self.render_rx = None;111
self.render_worker = None;105
self.render_ui = RenderUiState::idle();112
self.render_ui = RenderUiState::idle();113
self.pass_texture = None;106
}114
}107
115
108
fn sync_fields(&mut self) {116
fn sync_fields(&mut self) {@@ -345,27 +353,50 @@ impl EditorApp {345
self.session.set_render(width, height, fps);353
self.session.set_render(width, height, fps);346
}354
}347
355
348
/// Start the same worker used by the Render button (also used by the GUI smoke fixture).356
/// Open the body editor, also used by the native smoke example.357
pub fn open_body_configuration(&mut self, sex: Sex) {358
self.session.show_body(sex);359
self.controls.sex = match sex {360
Sex::Male => PreviewSex::Male,361
Sex::Female => PreviewSex::Female,362
};363
}364
pub fn render_error(&self) -> Option<&str> {365
self.render_ui.error.as_deref()366
}367
pub fn render_all_angles(&mut self) {368
self.start_render();369
}370
/// Legacy smoke entry point, now starts the production batch as well.349
pub fn render_current_pose(&mut self) {371
pub fn render_current_pose(&mut self) {350
self.start_render([480, 640]);372
self.render_all_angles();351
}373
}352
374
pub fn render_is_running(&self) -> bool {353
fn start_render(&mut self, preview: [u32; 2]) {375
self.render_ui.running354
if let Some(opened) = &self.session.opened {376
}355
if let Err(error) = opened.document.validate_shape() {377
pub fn render_progress(&self) -> (u32, u32, String) {356
self.session.fail(error.to_string());378
(357
return;379
self.render_ui.progress.completed,358
}380
self.render_ui.progress.total,381
self.render_ui.progress.label.clone(),382
)383
}384
fn start_render(&mut self) {385
if self.render_ui.running {386
return;359
}387
}360
let Some(character) = self.character().cloned() else {388
let Some(opened) = &self.session.opened else {361
self.session362
.fail("dit lichaam heeft geen glTF dat de preview kan tekenen");363
return;389
return;364
};390
};365
let Some(root) = self.session.project_root() else {391
if let Err(error) = opened.document.validate_shape() {392
self.session.fail(error.to_string());393
return;394
}395
let Some(character) = self.character().cloned() else {396
self.session.fail("dit lichaam heeft geen tekenbaar glTF");366
return;397
return;367
};398
};368
let Some(opened) = &self.session.opened else {399
let Some(root) = self.session.project_root() else {369
return;400
return;370
};401
};371
if let Err(error) =402
if let Err(error) =@@ -374,60 +405,145 @@ impl EditorApp {374
self.session.fail(error.to_string());405
self.session.fail(error.to_string());375
return;406
return;376
}407
}377
let sex = match self.controls.sex {408
let sex = usize::from(self.controls.sex == PreviewSex::Female);378
PreviewSex::Male => "male",409
let preset = match self.session.screen {379
PreviewSex::Female => "female",410
Screen::Equipment(i) => self.session.equipment.get(i).and_then(|i| i.preset.clone()),411
_ => None,380
};412
};381
let output = root413
if matches!(self.session.screen, Screen::Equipment(_)) && preset.is_none() {382
.join(&opened.document.output_directory)414
self.session.fail("configureer eerst het equipment");383
.join(format!("body-{sex}-still.png"));415
return;416
}384
let job = RenderJob {417
let job = RenderJob {385
character,418
character,386
controls: self.controls.clone(),419
sex: if sex == 0 { "male" } else { "female" }.into(),387
target_width: opened.document.render.target_width.max(1),420
preset,388
target_height: opened.document.render.target_height.max(1),421
target_width: opened.document.render.target_width,389
preview_width: preview[0].max(1),422
target_height: opened.document.render.target_height,390
preview_height: preview[1].max(1),423
fps: opened.document.render.fps,391
output,424
framing: opened.document.render.framing[sex],425
output: root.join(&opened.document.output_directory),392
};426
};393
let thread = spawn(job);427
self.render_worker = Some(spawn(job, None));394
self.render_rx = Some(thread.rx);395
self.render_ui = RenderUiState::idle();428
self.render_ui = RenderUiState::idle();429
self.pass_texture = None;396
self.render_ui.running = true;430
self.render_ui.running = true;397
let _ = thread.handle;398
}431
}399
432
fn service_gpu(&mut self, gl: Option<&std::sync::Arc<glow::Context>>) {433
let request = self434
.render_worker435
.as_ref()436
.and_then(|worker| worker.gpu.try_recv().ok());437
if let Some(request) = request {438
if let Some(gl) = gl {439
unsafe {440
request.service(&mut self.gpu_renderer, gl);441
}442
} else {443
request.fail("Glow-context ontbreekt".into());444
}445
}446
}400
fn drain_render(&mut self) {447
fn drain_render(&mut self) {401
let Some(rx) = &self.render_rx else {448
let Some(worker) = &self.render_worker else {402
return;449
return;403
};450
};404
loop {451
let ended = worker405
let event = match rx.try_recv() {452
.handle406
Ok(event) => event,453
.as_ref()407
Err(std::sync::mpsc::TryRecvError::Empty) => break,454
.map(|h| h.is_finished())408
Err(std::sync::mpsc::TryRecvError::Disconnected) => {455
.unwrap_or(true);409
let message = "De renderworker stopte zonder eindresultaat.".to_string();456
let events = worker.mailbox.drain();410
self.session.fail(message.clone());457
let mut terminal = false;411
self.render_ui.apply(RenderEvent::Failed(message));458
for event in events {412
self.render_rx = None;459
terminal |= matches!(413
break;460
event,414
}461
RenderEvent::Finished { .. } | RenderEvent::Failed(_) | RenderEvent::Cancelled415
};462
);416
let finished = matches!(event, RenderEvent::Finished { .. } | RenderEvent::Failed(_));417
if let RenderEvent::Finished { saved: Some(path) } = &event {463
if let RenderEvent::Finished { saved: Some(path) } = &event {418
self.session.notice = format!("Standbeeld opgeslagen: {path}");464
self.session.notice = format!("Batch opgeslagen: {path}");419
self.session.notice_ok = true;465
self.session.notice_ok = true;420
}466
}421
if let RenderEvent::Failed(message) = &event {467
if let RenderEvent::Failed(error) = &event {422
self.session.fail(message.clone());468
self.session.fail(error.clone());423
}469
}424
self.render_ui.apply(event);470
self.render_ui.apply(event);425
if finished {471
}426
self.render_rx = None;472
if terminal {427
break;473
self.render_worker = None;428
}474
} else if ended {475
self.render_ui.apply(RenderEvent::Failed(476
"De renderworker stopte zonder eindresultaat.".into(),477
));478
self.render_worker = None;479
}480
}481
fn cancel_render(&mut self) {482
if let Some(worker) = &self.render_worker {483
worker484
.cancel485
.store(true, std::sync::atomic::Ordering::Relaxed);486
}487
}488
fn render_progress_ui(&mut self, ui: &mut egui::Ui) {489
if self.render_ui.progress.total > 0 {490
ui.add(491
egui::ProgressBar::new(492
self.render_ui.progress.completed as f32493
/ self.render_ui.progress.total.max(1) as f32,494
)495
.text(format!(496
"{} / {} · {}",497
self.render_ui.progress.completed,498
self.render_ui.progress.total,499
self.render_ui.progress.label500
)),501
);502
}503
if self.render_ui.running && ui.button("Annuleer render").clicked() {504
self.cancel_render();429
}505
}430
}506
}507
fn framing_ui(&mut self, ui: &mut egui::Ui, sex: usize) {508
if let Some(opened) = &mut self.session.opened {509
let camera = &mut opened.document.render.framing[sex];510
let mut height = camera.height_milli as f32 / 1000.;511
let mut center = camera.center_y_milli as f32 / 1000.;512
ui.horizontal_wrapped(|ui| {513
ui.label("Vaste renderhoogte (m)");514
if ui515
.add(516
egui::DragValue::new(&mut height)517
.speed(0.01)518
.range(0.1..=10.),519
)520
.changed()521
{522
camera.height_milli = (height * 1000.).round() as u32;523
}524
ui.label("Centrum Y (m)");525
if ui526
.add(527
egui::DragValue::new(&mut center)528
.speed(0.01)529
.range(-10.0..=10.0),530
)531
.changed()532
{533
camera.center_y_milli = (center * 1000.).round() as i32;534
}535
ui.checkbox(&mut self.show_render_frame, "Vast renderkader tonen");536
});537
}538
}539
fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> {540
if !self.show_render_frame {541
return None;542
}543
let r = &self.session.opened.as_ref()?.document.render;544
let sex = usize::from(self.controls.sex == PreviewSex::Female);545
Some((r.framing[sex], [r.target_width, r.target_height]))546
}431
}547
}432
548
433
impl Default for EditorApp {549
impl Default for EditorApp {@@ -438,11 +554,16 @@ impl Default for EditorApp {438
554
439
impl eframe::App for EditorApp {555
impl eframe::App for EditorApp {440
fn on_exit(&mut self, gl: Option<&glow::Context>) {556
fn on_exit(&mut self, gl: Option<&glow::Context>) {557
self.render_worker = None;441
if let Some(gl) = gl {558
if let Some(gl) = gl {559
unsafe {560
self.gpu_renderer.destroy(gl);561
}442
self.viewport.destroy(gl);562
self.viewport.destroy(gl);443
}563
}444
}564
}445
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {565
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {566
self.service_gpu(frame.gl());446
let shell = self.session.shell_size();567
let shell = self.session.shell_size();447
if self.applied_shell != Some(shell) {568
if self.applied_shell != Some(shell) {448
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(569
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(@@ -645,34 +766,31 @@ impl EditorApp {645
self.session.show_equipment_list();766
self.session.show_equipment_list();646
}767
}647
if ui768
if ui648
.add_enabled(!self.render_ui.running, egui::Button::new("Render"))769
.add_enabled(770
!self.render_ui.running,771
egui::Button::new("Render alle hoeken"),772
)649
.clicked()773
.clicked()650
{774
{651
self.start_render([480, 640]);775
self.start_render();652
}776
}653
if ui.button("Live 3D").clicked() {777
if ui.button("Live 3D").clicked() {654
self.render_ui.preview = None;778
self.render_ui.preview = None;655
}779
}656
});780
});657
if self.render_ui.progress.total > 0 {781
self.framing_ui(ui, usize::from(sex == Sex::Female));658
let fraction = self.render_ui.progress.completed as f32782
self.render_progress_ui(ui);659
/ self.render_ui.progress.total.max(1) as f32;783
if self.render_ui.preview.is_some() {660
ui.add(egui::ProgressBar::new(fraction).text(format!(784
self.paint_pass(ui);661
"{} / {} {}",662
self.render_ui.progress.completed,663
self.render_ui.progress.total,664
self.render_ui.progress.label665
)));666
}667
if let Some(image) = &self.render_ui.preview {668
self.paint_pass(ui, image);669
} else {785
} else {670
let character = match self.controls.sex {786
let character = match self.controls.sex {671
PreviewSex::Male => self.preview_male.as_ref(),787
PreviewSex::Male => self.preview_male.as_ref(),672
PreviewSex::Female => self.preview_female.as_ref(),788
PreviewSex::Female => self.preview_female.as_ref(),673
};789
};674
if character.is_some() {790
if character.is_some() {675
self.viewport.show(ui, &mut self.controls, character, &[]);791
let framing = self.fixed_camera();792
self.viewport793
.show_framed(ui, &mut self.controls, character, &[], framing);676
} else {794
} else {677
ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");795
ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");678
}796
}@@ -680,21 +798,30 @@ impl EditorApp {680
});798
});681
}799
}682
800
683
fn paint_pass(&self, ui: &mut egui::Ui, image: &crate::render_job::ImageBuf) {801
fn paint_pass(&mut self, ui: &mut egui::Ui) {684
let color = egui::ColorImage::from_rgba_unmultiplied(802
let Some(image) = &self.render_ui.preview else {685
[image.width as usize, image.height as usize],803
return;686
&image.rgba,804
};687
);805
ui.label(format!(688
let texture = ui.ctx().load_texture(806
"Laatste voltooide pass: {}",689
"render-pass",807
self.render_ui.preview_label690
color,808
));691
egui::TextureOptions {809
if self.pass_texture.is_none()692
magnification: egui::TextureFilter::Nearest,810
|| self.pass_texture_revision != self.render_ui.preview_revision693
minification: egui::TextureFilter::Nearest,811
{694
wrap_mode: egui::TextureWrapMode::ClampToEdge,812
let color = egui::ColorImage::from_rgba_unmultiplied(695
..Default::default()813
[image.width as usize, image.height as usize],696
},814
&image.rgba,697
);815
);816
let options = egui::TextureOptions::NEAREST;817
if let Some(texture) = &mut self.pass_texture {818
texture.set(color, options);819
} else {820
self.pass_texture = Some(ui.ctx().load_texture("render-pass", color, options));821
}822
self.pass_texture_revision = self.render_ui.preview_revision;823
}824
let texture = self.pass_texture.as_ref().expect("pass texture installed");698
let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());825
let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());699
let fit = crate::render_job::fit_preview(826
let fit = crate::render_job::fit_preview(700
[image.width, image.height],827
[image.width, image.height],@@ -949,6 +1076,26 @@ impl EditorApp {949
if self.session.confirm.is_some() || self.dialog_pending {1076
if self.session.confirm.is_some() || self.dialog_pending {950
ui.disable();1077
ui.disable();951
}1078
}1079
ui.horizontal_wrapped(|ui| {1080
if ui1081
.add_enabled(1082
!self.render_ui.running,1083
egui::Button::new("Render equipment · alle hoeken"),1084
)1085
.clicked()1086
{1087
self.start_render();1088
}1089
if ui.button("Live 3D").clicked() {1090
self.render_ui.preview = None;1091
}1092
});1093
self.render_progress_ui(ui);1094
if self.render_ui.preview.is_some() {1095
self.paint_pass(ui);1096
return;1097
}1098
let framing = self.fixed_camera();952
let body = if sex == 0 {1099
let body = if sex == 0 {953
self.preview_male.as_ref()1100
self.preview_male.as_ref()954
} else {1101
} else {@@ -960,7 +1107,8 @@ impl EditorApp {960
.get(index)1107
.get(index)961
.and_then(|item| item.preset.as_ref());1108
.and_then(|item| item.preset.as_ref());962
let presets: Vec<_> = preset.into_iter().collect();1109
let presets: Vec<_> = preset.into_iter().collect();963
self.viewport.show(ui, &mut self.controls, body, &presets);1110
self.viewport1111
.show_framed(ui, &mut self.controls, body, &presets, framing);964
});1112
});965
}1113
}966
1114
@@ -1136,10 +1284,14 @@ mod tests {1136
#[test]1284
#[test]1137
fn renderer_disconnect_is_reported() {1285
fn renderer_disconnect_is_reported() {1138
let mut app = EditorApp::default();1286
let mut app = EditorApp::default();1139
let (tx, rx) = std::sync::mpsc::channel();1287
let (_, gpu) = std::sync::mpsc::channel();1140
app.render_rx = Some(rx);1288
app.render_worker = Some(crate::production::RenderThread {1289
mailbox: Default::default(),1290
gpu,1291
cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),1292
handle: None,1293
});1141
app.render_ui.running = true;1294
app.render_ui.running = true;1142
drop(tx);1143
app.drain_render();1295
app.drain_render();1144
assert!(!app.render_ui.running);1296
assert!(!app.render_ui.running);1145
assert!(app.render_ui.error.is_some());1297
assert!(app.render_ui.error.is_some());@@ -1,7 +1,7 @@
1
1
//! Honest capability report for the current editor.
2
2
//!
3
//! Attachment authoring is present. Full production paperdoll export and the
4
//! rendered 2D compositor remain not built; a shaded still is not that batch.
3
//! Attachment authoring and target-map production batches are present.
4
//! The rendered 2D lighting/compositor remains not built.
5
5
6
6
/// Product capability named by the design.
7
7
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -64,8 +64,9 @@ pub fn availability(capability: Capability) -> Availability {
64
64
| Capability::GltfGlb
65
65
| Capability::Fbx
66
66
| Capability::SkeletalAnimation
67
| Capability::AttachmentEditor => Availability::Present,
68
Capability::PaperdollRender | Capability::Preview2d => Availability::NotBuilt,
67
| Capability::AttachmentEditor
68
| Capability::PaperdollRender => Availability::Present,
69
Capability::Preview2d => Availability::NotBuilt,
69
70
}
70
71
}
71
72
@@ -110,7 +111,7 @@ pub fn dutch_state(availability: Availability) -> &'static str {
110
111
}
111
112
112
113
pub fn dutch_summary() -> &'static str {
113
"Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB met walk en attack, rigide bone-gebonden OBJ-equipment en binaire .ppe-presets. Render maakt een gekleurd body-stilbeeld, geen productie-batch."
114
"Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB met walk en attack, rigide bone-gebonden OBJ-equipment en binaire .ppe-presets. Render maakt alle clips × frames × 36 hoeken met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie. De 2D-lichtcompositor is niet gebouwd."
114
115
}
115
116
116
117
/// Stable status text shared by `--status` and the window.
@@ -232,7 +233,7 @@ mod tests {
232
233
use super::*;
233
234
234
235
#[test]
235
fn import_formats_are_present_and_render_is_not() {
236
fn import_and_production_are_present_but_compositor_is_not() {
236
237
assert_eq!(
237
238
availability(Capability::ProjectShell),
238
239
Availability::Present
@@ -251,7 +252,12 @@ mod tests {
251
252
availability(Capability::AttachmentEditor),
252
253
Availability::Present
253
254
);
254
for capability in [Capability::PaperdollRender, Capability::Preview2d] {
255
assert_eq!(
256
availability(Capability::PaperdollRender),
257
Availability::Present
258
);
259
{
260
let capability = Capability::Preview2d;
255
261
assert_eq!(
256
262
availability(capability),
257
263
Availability::NotBuilt,
@@ -261,7 +267,7 @@ mod tests {
261
267
}
262
268
263
269
#[test]
264
fn status_report_matches_current_equipment_build_without_claiming_production() {
270
fn status_report_matches_current_production_without_claiming_compositor() {
265
271
let report = report();
266
272
assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-editor\n"));
267
273
for key in [
@@ -271,13 +277,15 @@ mod tests {
271
277
"fbx",
272
278
"skeletal_animation",
273
279
"attachment_editor",
280
"paperdoll_render",
274
281
] {
275
282
assert!(
276
283
report.contains(&format!("{key}: present")),
277
284
"missing present {key} in {report}"
278
285
);
279
286
}
280
for key in ["paperdoll_render", "preview_2d"] {
287
{
288
let key = "preview_2d";
281
289
assert!(
282
290
report.contains(&format!("{key}: not_built")),
283
291
"missing {key} in {report}"
@@ -1,7 +1,7 @@1
//! Honest capability report for the current editor.1
//! Honest capability report for the current editor.2
//!2
//!3
//! Attachment authoring is present. Full production paperdoll export and the3
//! Attachment authoring and target-map production batches are present.4
//! rendered 2D compositor remain not built; a shaded still is not that batch.4
//! The rendered 2D lighting/compositor remains not built.5
5
6
/// Product capability named by the design.6
/// Product capability named by the design.7
#[derive(Debug, Clone, Copy, PartialEq, Eq)]7
#[derive(Debug, Clone, Copy, PartialEq, Eq)]@@ -64,8 +64,9 @@ pub fn availability(capability: Capability) -> Availability {64
| Capability::GltfGlb64
| Capability::GltfGlb65
| Capability::Fbx65
| Capability::Fbx66
| Capability::SkeletalAnimation66
| Capability::SkeletalAnimation67
| Capability::AttachmentEditor => Availability::Present,67
| Capability::AttachmentEditor68
Capability::PaperdollRender | Capability::Preview2d => Availability::NotBuilt,68
| Capability::PaperdollRender => Availability::Present,69
Capability::Preview2d => Availability::NotBuilt,69
}70
}70
}71
}71
72
@@ -110,7 +111,7 @@ pub fn dutch_state(availability: Availability) -> &'static str {110
}111
}111
112
112
pub fn dutch_summary() -> &'static str {113
pub fn dutch_summary() -> &'static str {113
"Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB met walk en attack, rigide bone-gebonden OBJ-equipment en binaire .ppe-presets. Render maakt een gekleurd body-stilbeeld, geen productie-batch."114
"Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB met walk en attack, rigide bone-gebonden OBJ-equipment en binaire .ppe-presets. Render maakt alle clips × frames × 36 hoeken met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie. De 2D-lichtcompositor is niet gebouwd."114
}115
}115
116
116
/// Stable status text shared by `--status` and the window.117
/// Stable status text shared by `--status` and the window.@@ -232,7 +233,7 @@ mod tests {232
use super::*;233
use super::*;233
234
234
#[test]235
#[test]235
fn import_formats_are_present_and_render_is_not() {236
fn import_and_production_are_present_but_compositor_is_not() {236
assert_eq!(237
assert_eq!(237
availability(Capability::ProjectShell),238
availability(Capability::ProjectShell),238
Availability::Present239
Availability::Present@@ -251,7 +252,12 @@ mod tests {251
availability(Capability::AttachmentEditor),252
availability(Capability::AttachmentEditor),252
Availability::Present253
Availability::Present253
);254
);254
for capability in [Capability::PaperdollRender, Capability::Preview2d] {255
assert_eq!(256
availability(Capability::PaperdollRender),257
Availability::Present258
);259
{260
let capability = Capability::Preview2d;255
assert_eq!(261
assert_eq!(256
availability(capability),262
availability(capability),257
Availability::NotBuilt,263
Availability::NotBuilt,@@ -261,7 +267,7 @@ mod tests {261
}267
}262
268
263
#[test]269
#[test]264
fn status_report_matches_current_equipment_build_without_claiming_production() {270
fn status_report_matches_current_production_without_claiming_compositor() {265
let report = report();271
let report = report();266
assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-editor\n"));272
assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-editor\n"));267
for key in [273
for key in [@@ -271,13 +277,15 @@ mod tests {271
"fbx",277
"fbx",272
"skeletal_animation",278
"skeletal_animation",273
"attachment_editor",279
"attachment_editor",280
"paperdoll_render",274
] {281
] {275
assert!(282
assert!(276
report.contains(&format!("{key}: present")),283
report.contains(&format!("{key}: present")),277
"missing present {key} in {report}"284
"missing present {key} in {report}"278
);285
);279
}286
}280
for key in ["paperdoll_render", "preview_2d"] {287
{288
let key = "preview_2d";281
assert!(289
assert!(282
report.contains(&format!("{key}: not_built")),290
report.contains(&format!("{key}: not_built")),283
"missing {key} in {report}"291
"missing {key} in {report}"@@ -1,7 +1,7 @@
1
1
//! Paperdoll Renderer library.
2
2
//!
3
3
//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
4
//! and draws a live skinned glTF preview.
4
//! draws skinned body/equipment previews, and streams production target maps.
5
5
6
6
pub mod dialogs;
7
7
pub mod editor;
@@ -12,6 +12,7 @@ pub mod icon;
12
12
pub mod import;
13
13
pub mod preview;
14
14
pub mod preview_gl;
15
pub mod production;
15
16
pub mod project;
16
17
pub mod render_job;
17
18
pub mod session;
@@ -1,7 +1,7 @@1
//! Paperdoll Renderer library.1
//! Paperdoll Renderer library.2
//!2
//!3
//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,3
//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,4
//! and draws a live skinned glTF preview.4
//! draws skinned body/equipment previews, and streams production target maps.5
5
6
pub mod dialogs;6
pub mod dialogs;7
pub mod editor;7
pub mod editor;@@ -12,6 +12,7 @@ pub mod icon;12
pub mod import;12
pub mod import;13
pub mod preview;13
pub mod preview;14
pub mod preview_gl;14
pub mod preview_gl;15
pub mod production;15
pub mod project;16
pub mod project;16
pub mod render_job;17
pub mod render_job;17
pub mod session;18
pub mod session;@@ -87,13 +87,28 @@ pub struct PreviewCharacter {
87
87
pub inverse_binds: Vec<Mat4>,
88
88
pub positions: Vec<[f32; 3]>,
89
89
pub normals: Vec<[f32; 3]>,
90
/// A placement-only fallback must never masquerade as a production normal map.
91
pub source_normals_complete: bool,
90
92
pub joints: Vec<[u16; 4]>,
91
93
pub weights: Vec<[f32; 4]>,
92
94
pub indices: Vec<u32>,
93
95
pub base_color: [f32; 4],
96
pub colors: Vec<[f32; 4]>,
97
pub uvs: Vec<[f32; 2]>,
98
pub surfaces: Vec<BodySurface>,
94
99
pub clips: Vec<PreviewClip>,
95
100
}
96
101
102
#[derive(Debug, Clone)]
103
pub struct BodySurface {
104
pub first: usize,
105
pub count: usize,
106
pub color: [f32; 4],
107
pub albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
108
pub cutoff: f32,
109
pub flat_normals: bool,
110
}
111
97
112
#[derive(Debug, Clone)]
98
113
pub struct SceneNode {
99
114
pub name: String,
@@ -126,7 +141,7 @@ pub struct AnimChannel {
126
141
}
127
142
128
143
pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
129
let (document, buffers, _images) = gltf::import(path).map_err(|err| err.to_string())?;
144
let (document, buffers, images) = gltf::import(path).map_err(|err| err.to_string())?;
130
145
let mut nodes = Vec::new();
131
146
let mut parents = vec![None; document.nodes().len()];
132
147
for node in document.nodes() {
@@ -169,17 +184,25 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
169
184
}
170
185
let mut positions = Vec::new();
171
186
let mut normals = Vec::new();
187
let mut source_normals_complete = true;
172
188
let mut joints = Vec::new();
173
189
let mut weights = Vec::new();
174
190
let mut indices = Vec::new();
175
191
let mut base_color = [0.7, 0.7, 0.75, 1.0];
192
let mut colors = Vec::new();
193
let mut uvs = Vec::new();
194
let mut surfaces = Vec::new();
176
195
if let Some(mesh) = document.meshes().next() {
177
196
for primitive in mesh.primitives() {
197
if primitive.mode() != gltf::mesh::Mode::Triangles {
198
return Err("live/productie ondersteunt alleen driehoekprimitieven".into());
199
}
178
200
let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
179
201
let prim_positions: Vec<[f32; 3]> = reader
180
202
.read_positions()
181
203
.map(|iter| iter.collect())
182
204
.unwrap_or_default();
205
source_normals_complete &= reader.read_normals().is_some();
183
206
let prim_normals: Vec<[f32; 3]> = reader
184
207
.read_normals()
185
208
.map(|iter| iter.collect())
@@ -192,6 +215,23 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
192
215
.read_weights(0)
193
216
.map(|iter| iter.into_f32().collect())
194
217
.unwrap_or_else(|| vec![[1.0, 0.0, 0.0, 0.0]; prim_positions.len()]);
218
let material = primitive.material();
219
let pbr = material.pbr_metallic_roughness();
220
let texture = pbr.base_color_texture();
221
let uvset = texture.as_ref().map(|t| t.tex_coord()).unwrap_or(0);
222
colors.extend(
223
reader
224
.read_colors(0)
225
.map(|v| v.into_rgba_f32().collect::<Vec<_>>())
226
.unwrap_or_else(|| vec![[1.; 4]; prim_positions.len()]),
227
);
228
uvs.extend(
229
reader
230
.read_tex_coords(uvset)
231
.map(|v| v.into_f32().collect::<Vec<_>>())
232
.unwrap_or_else(|| vec![[0.; 2]; prim_positions.len()]),
233
);
234
let first = indices.len();
195
235
let base = positions.len() as u32;
196
236
if let Some(read_indices) = reader.read_indices() {
197
237
indices.extend(read_indices.into_u32().map(|index| index + base));
@@ -206,6 +246,54 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
206
246
.material()
207
247
.pbr_metallic_roughness()
208
248
.base_color_factor();
249
let albedo = texture
250
.map(|t| {
251
let image = images
252
.get(t.texture().source().index())
253
.ok_or("ontbrekende bodytexture")?;
254
use gltf::image::Format;
255
let channels = match image.format {
256
Format::R8 => 1,
257
Format::R8G8 => 2,
258
Format::R8G8B8 => 3,
259
Format::R8G8B8A8 => 4,
260
_ => return Err("bodytexture moet 8-bit zijn".to_string()),
261
};
262
if image.width > 2048 || image.height > 2048 {
263
return Err("bodytexture groter dan 2048×2048".into());
264
}
265
let mut rgba = Vec::new();
266
for p in image.pixels.chunks_exact(channels) {
267
rgba.extend_from_slice(&match channels {
268
1 => [p[0], p[0], p[0], 255],
269
2 => [p[0], p[0], p[0], p[1]],
270
3 => [p[0], p[1], p[2], 255],
271
_ => [p[0], p[1], p[2], p[3]],
272
});
273
}
274
use std::hash::{Hash, Hasher};
275
let mut hash = std::collections::hash_map::DefaultHasher::new();
276
rgba.hash(&mut hash);
277
Ok(std::sync::Arc::new(crate::equipment::Texture {
278
content_hash: hash.finish(),
279
width: image.width,
280
height: image.height,
281
rgba,
282
}))
283
})
284
.transpose()?;
285
surfaces.push(BodySurface {
286
flat_normals: reader.read_normals().is_none(),
287
first,
288
count: indices.len() - first,
289
color: base_color,
290
albedo,
291
cutoff: if material.alpha_mode() == gltf::material::AlphaMode::Mask {
292
material.alpha_cutoff().unwrap_or(0.5)
293
} else {
294
0.001
295
},
296
});
209
297
}
210
298
}
211
299
if positions.is_empty() || indices.len() < 3 {
@@ -260,10 +348,14 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
260
348
inverse_binds,
261
349
positions,
262
350
normals,
351
source_normals_complete,
263
352
joints,
264
353
weights,
265
354
indices,
266
355
base_color,
356
colors,
357
uvs,
358
surfaces,
267
359
clips,
268
360
})
269
361
}
@@ -87,13 +87,28 @@ pub struct PreviewCharacter {87
pub inverse_binds: Vec<Mat4>,87
pub inverse_binds: Vec<Mat4>,88
pub positions: Vec<[f32; 3]>,88
pub positions: Vec<[f32; 3]>,89
pub normals: Vec<[f32; 3]>,89
pub normals: Vec<[f32; 3]>,90
/// A placement-only fallback must never masquerade as a production normal map.91
pub source_normals_complete: bool,90
pub joints: Vec<[u16; 4]>,92
pub joints: Vec<[u16; 4]>,91
pub weights: Vec<[f32; 4]>,93
pub weights: Vec<[f32; 4]>,92
pub indices: Vec<u32>,94
pub indices: Vec<u32>,93
pub base_color: [f32; 4],95
pub base_color: [f32; 4],96
pub colors: Vec<[f32; 4]>,97
pub uvs: Vec<[f32; 2]>,98
pub surfaces: Vec<BodySurface>,94
pub clips: Vec<PreviewClip>,99
pub clips: Vec<PreviewClip>,95
}100
}96
101
102
#[derive(Debug, Clone)]103
pub struct BodySurface {104
pub first: usize,105
pub count: usize,106
pub color: [f32; 4],107
pub albedo: Option<std::sync::Arc<crate::equipment::Texture>>,108
pub cutoff: f32,109
pub flat_normals: bool,110
}111
97
#[derive(Debug, Clone)]112
#[derive(Debug, Clone)]98
pub struct SceneNode {113
pub struct SceneNode {99
pub name: String,114
pub name: String,@@ -126,7 +141,7 @@ pub struct AnimChannel {126
}141
}127
142
128
pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {143
pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {129
let (document, buffers, _images) = gltf::import(path).map_err(|err| err.to_string())?;144
let (document, buffers, images) = gltf::import(path).map_err(|err| err.to_string())?;130
let mut nodes = Vec::new();145
let mut nodes = Vec::new();131
let mut parents = vec![None; document.nodes().len()];146
let mut parents = vec![None; document.nodes().len()];132
for node in document.nodes() {147
for node in document.nodes() {@@ -169,17 +184,25 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {169
}184
}170
let mut positions = Vec::new();185
let mut positions = Vec::new();171
let mut normals = Vec::new();186
let mut normals = Vec::new();187
let mut source_normals_complete = true;172
let mut joints = Vec::new();188
let mut joints = Vec::new();173
let mut weights = Vec::new();189
let mut weights = Vec::new();174
let mut indices = Vec::new();190
let mut indices = Vec::new();175
let mut base_color = [0.7, 0.7, 0.75, 1.0];191
let mut base_color = [0.7, 0.7, 0.75, 1.0];192
let mut colors = Vec::new();193
let mut uvs = Vec::new();194
let mut surfaces = Vec::new();176
if let Some(mesh) = document.meshes().next() {195
if let Some(mesh) = document.meshes().next() {177
for primitive in mesh.primitives() {196
for primitive in mesh.primitives() {197
if primitive.mode() != gltf::mesh::Mode::Triangles {198
return Err("live/productie ondersteunt alleen driehoekprimitieven".into());199
}178
let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));200
let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));179
let prim_positions: Vec<[f32; 3]> = reader201
let prim_positions: Vec<[f32; 3]> = reader180
.read_positions()202
.read_positions()181
.map(|iter| iter.collect())203
.map(|iter| iter.collect())182
.unwrap_or_default();204
.unwrap_or_default();205
source_normals_complete &= reader.read_normals().is_some();183
let prim_normals: Vec<[f32; 3]> = reader206
let prim_normals: Vec<[f32; 3]> = reader184
.read_normals()207
.read_normals()185
.map(|iter| iter.collect())208
.map(|iter| iter.collect())@@ -192,6 +215,23 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {192
.read_weights(0)215
.read_weights(0)193
.map(|iter| iter.into_f32().collect())216
.map(|iter| iter.into_f32().collect())194
.unwrap_or_else(|| vec![[1.0, 0.0, 0.0, 0.0]; prim_positions.len()]);217
.unwrap_or_else(|| vec![[1.0, 0.0, 0.0, 0.0]; prim_positions.len()]);218
let material = primitive.material();219
let pbr = material.pbr_metallic_roughness();220
let texture = pbr.base_color_texture();221
let uvset = texture.as_ref().map(|t| t.tex_coord()).unwrap_or(0);222
colors.extend(223
reader224
.read_colors(0)225
.map(|v| v.into_rgba_f32().collect::<Vec<_>>())226
.unwrap_or_else(|| vec![[1.; 4]; prim_positions.len()]),227
);228
uvs.extend(229
reader230
.read_tex_coords(uvset)231
.map(|v| v.into_f32().collect::<Vec<_>>())232
.unwrap_or_else(|| vec![[0.; 2]; prim_positions.len()]),233
);234
let first = indices.len();195
let base = positions.len() as u32;235
let base = positions.len() as u32;196
if let Some(read_indices) = reader.read_indices() {236
if let Some(read_indices) = reader.read_indices() {197
indices.extend(read_indices.into_u32().map(|index| index + base));237
indices.extend(read_indices.into_u32().map(|index| index + base));@@ -206,6 +246,54 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {206
.material()246
.material()207
.pbr_metallic_roughness()247
.pbr_metallic_roughness()208
.base_color_factor();248
.base_color_factor();249
let albedo = texture250
.map(|t| {251
let image = images252
.get(t.texture().source().index())253
.ok_or("ontbrekende bodytexture")?;254
use gltf::image::Format;255
let channels = match image.format {256
Format::R8 => 1,257
Format::R8G8 => 2,258
Format::R8G8B8 => 3,259
Format::R8G8B8A8 => 4,260
_ => return Err("bodytexture moet 8-bit zijn".to_string()),261
};262
if image.width > 2048 || image.height > 2048 {263
return Err("bodytexture groter dan 2048×2048".into());264
}265
let mut rgba = Vec::new();266
for p in image.pixels.chunks_exact(channels) {267
rgba.extend_from_slice(&match channels {268
1 => [p[0], p[0], p[0], 255],269
2 => [p[0], p[0], p[0], p[1]],270
3 => [p[0], p[1], p[2], 255],271
_ => [p[0], p[1], p[2], p[3]],272
});273
}274
use std::hash::{Hash, Hasher};275
let mut hash = std::collections::hash_map::DefaultHasher::new();276
rgba.hash(&mut hash);277
Ok(std::sync::Arc::new(crate::equipment::Texture {278
content_hash: hash.finish(),279
width: image.width,280
height: image.height,281
rgba,282
}))283
})284
.transpose()?;285
surfaces.push(BodySurface {286
flat_normals: reader.read_normals().is_none(),287
first,288
count: indices.len() - first,289
color: base_color,290
albedo,291
cutoff: if material.alpha_mode() == gltf::material::AlphaMode::Mask {292
material.alpha_cutoff().unwrap_or(0.5)293
} else {294
0.001295
},296
});209
}297
}210
}298
}211
if positions.is_empty() || indices.len() < 3 {299
if positions.is_empty() || indices.len() < 3 {@@ -260,10 +348,14 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {260
inverse_binds,348
inverse_binds,261
positions,349
positions,262
normals,350
normals,351
source_normals_complete,263
joints,352
joints,264
weights,353
weights,265
indices,354
indices,266
base_color,355
base_color,356
colors,357
uvs,358
surfaces,267
clips,359
clips,268
})360
})269
}361
}@@ -56,6 +56,18 @@ impl Viewport {
56
56
character: Option<&PreviewCharacter>,
57
57
presets: &[&crate::equipment::Preset],
58
58
) {
59
self.show_framed(ui, controls, character, presets, None);
60
}
61
62
pub fn show_framed(
63
&self,
64
ui: &mut egui::Ui,
65
controls: &mut PreviewControls,
66
character: Option<&PreviewCharacter>,
67
presets: &[&crate::equipment::Preset],
68
framing: Option<(crate::project::CameraFraming, [u32; 2])>,
69
) {
70
let framing = framing.filter(|(c, r)| c.validate().is_ok() && r[0] > 0 && r[1] > 0);
59
71
ui.horizontal_wrapped(|ui| {
60
72
if let Some(character) = character {
61
73
if !character
@@ -109,12 +121,21 @@ impl Viewport {
109
121
available.min,
110
122
egui::vec2(available.width().max(240.0), available.height().max(240.0)),
111
123
);
124
let rect = if let Some((_, size)) = framing {
125
let fit = crate::render_job::fit_preview(size, [rect.width(), rect.height()]);
126
egui::Rect::from_min_size(
127
rect.min + egui::vec2(fit.offset[0], fit.offset[1]),
128
egui::vec2(fit.size[0], fit.size[1]),
129
)
130
} else {
131
rect
132
};
112
133
let response = ui.allocate_rect(rect, egui::Sense::click_and_drag());
113
134
if response.dragged() {
114
135
controls.drag(response.drag_delta().x, response.drag_delta().y);
115
136
}
116
137
let scroll = ui.input(|input| input.smooth_scroll_delta.y);
117
if response.hovered() && scroll.abs() > 0.0 {
138
if framing.is_none() && response.hovered() && scroll.abs() > 0.0 {
118
139
controls.zoom(scroll);
119
140
}
120
141
let Some(character) = character else {
@@ -135,8 +156,22 @@ impl Viewport {
135
156
ui.ctx().request_repaint();
136
157
let bones = sample_skin_matrices(character, &controls.clip_name, controls.time);
137
158
let aspect = rect.width() / rect.height().max(1.0);
138
let target = [0.0, 0.9, 0.0];
139
let offset = eye_offset(controls.yaw_deg, controls.pitch_deg, controls.distance);
159
let target = [
160
0.0,
161
framing
162
.map(|(c, _)| c.center_y_milli as f32 / 1000.)
163
.unwrap_or(0.9),
164
0.0,
165
];
166
let offset = eye_offset(
167
controls.yaw_deg,
168
controls.pitch_deg,
169
if framing.is_some() {
170
10.
171
} else {
172
controls.distance
173
},
174
);
140
175
let eye = [
141
176
target[0] + offset[0],
142
177
target[1] + offset[1],
@@ -146,6 +181,13 @@ impl Viewport {
146
181
perspective(38.0, aspect, 0.05, 40.0),
147
182
view_matrix(eye, target),
148
183
);
184
let view_proj = if let Some((camera, _)) = framing {
185
let (v, p) =
186
crate::production::camera(camera, controls.yaw_deg, controls.pitch_deg, aspect);
187
mul(p, v)
188
} else {
189
view_proj
190
};
149
191
let mut cache = self.cache.lock().expect("preview gpu lock");
150
192
let mut diagnostics = Vec::new();
151
193
let mut equipment = Vec::new();
@@ -203,6 +245,18 @@ impl Viewport {
203
245
rect,
204
246
callback: Arc::new(callback),
205
247
});
248
if framing.is_some() {
249
ui.painter()
250
.rect_stroke(rect, 0., egui::Stroke::new(2., egui::Color32::YELLOW));
251
ui.painter().text(
252
rect.left_top() + egui::vec2(5., 5.),
253
egui::Align2::LEFT_TOP,
254
"Vast renderkader",
255
egui::FontId::proportional(12.),
256
egui::Color32::YELLOW,
257
);
258
}
259
206
260
if let Ok(cache) = self.cache.lock() {
207
261
if let Some(error) = &cache.error {
208
262
diagnostics.push(error.clone());
@@ -56,6 +56,18 @@ impl Viewport {56
character: Option<&PreviewCharacter>,56
character: Option<&PreviewCharacter>,57
presets: &[&crate::equipment::Preset],57
presets: &[&crate::equipment::Preset],58
) {58
) {59
self.show_framed(ui, controls, character, presets, None);60
}61
62
pub fn show_framed(63
&self,64
ui: &mut egui::Ui,65
controls: &mut PreviewControls,66
character: Option<&PreviewCharacter>,67
presets: &[&crate::equipment::Preset],68
framing: Option<(crate::project::CameraFraming, [u32; 2])>,69
) {70
let framing = framing.filter(|(c, r)| c.validate().is_ok() && r[0] > 0 && r[1] > 0);59
ui.horizontal_wrapped(|ui| {71
ui.horizontal_wrapped(|ui| {60
if let Some(character) = character {72
if let Some(character) = character {61
if !character73
if !character@@ -109,12 +121,21 @@ impl Viewport {109
available.min,121
available.min,110
egui::vec2(available.width().max(240.0), available.height().max(240.0)),122
egui::vec2(available.width().max(240.0), available.height().max(240.0)),111
);123
);124
let rect = if let Some((_, size)) = framing {125
let fit = crate::render_job::fit_preview(size, [rect.width(), rect.height()]);126
egui::Rect::from_min_size(127
rect.min + egui::vec2(fit.offset[0], fit.offset[1]),128
egui::vec2(fit.size[0], fit.size[1]),129
)130
} else {131
rect132
};112
let response = ui.allocate_rect(rect, egui::Sense::click_and_drag());133
let response = ui.allocate_rect(rect, egui::Sense::click_and_drag());113
if response.dragged() {134
if response.dragged() {114
controls.drag(response.drag_delta().x, response.drag_delta().y);135
controls.drag(response.drag_delta().x, response.drag_delta().y);115
}136
}116
let scroll = ui.input(|input| input.smooth_scroll_delta.y);137
let scroll = ui.input(|input| input.smooth_scroll_delta.y);117
if response.hovered() && scroll.abs() > 0.0 {138
if framing.is_none() && response.hovered() && scroll.abs() > 0.0 {118
controls.zoom(scroll);139
controls.zoom(scroll);119
}140
}120
let Some(character) = character else {141
let Some(character) = character else {@@ -135,8 +156,22 @@ impl Viewport {135
ui.ctx().request_repaint();156
ui.ctx().request_repaint();136
let bones = sample_skin_matrices(character, &controls.clip_name, controls.time);157
let bones = sample_skin_matrices(character, &controls.clip_name, controls.time);137
let aspect = rect.width() / rect.height().max(1.0);158
let aspect = rect.width() / rect.height().max(1.0);138
let target = [0.0, 0.9, 0.0];159
let target = [139
let offset = eye_offset(controls.yaw_deg, controls.pitch_deg, controls.distance);160
0.0,161
framing162
.map(|(c, _)| c.center_y_milli as f32 / 1000.)163
.unwrap_or(0.9),164
0.0,165
];166
let offset = eye_offset(167
controls.yaw_deg,168
controls.pitch_deg,169
if framing.is_some() {170
10.171
} else {172
controls.distance173
},174
);140
let eye = [175
let eye = [141
target[0] + offset[0],176
target[0] + offset[0],142
target[1] + offset[1],177
target[1] + offset[1],@@ -146,6 +181,13 @@ impl Viewport {146
perspective(38.0, aspect, 0.05, 40.0),181
perspective(38.0, aspect, 0.05, 40.0),147
view_matrix(eye, target),182
view_matrix(eye, target),148
);183
);184
let view_proj = if let Some((camera, _)) = framing {185
let (v, p) =186
crate::production::camera(camera, controls.yaw_deg, controls.pitch_deg, aspect);187
mul(p, v)188
} else {189
view_proj190
};149
let mut cache = self.cache.lock().expect("preview gpu lock");191
let mut cache = self.cache.lock().expect("preview gpu lock");150
let mut diagnostics = Vec::new();192
let mut diagnostics = Vec::new();151
let mut equipment = Vec::new();193
let mut equipment = Vec::new();@@ -203,6 +245,18 @@ impl Viewport {203
rect,245
rect,204
callback: Arc::new(callback),246
callback: Arc::new(callback),205
});247
});248
if framing.is_some() {249
ui.painter()250
.rect_stroke(rect, 0., egui::Stroke::new(2., egui::Color32::YELLOW));251
ui.painter().text(252
rect.left_top() + egui::vec2(5., 5.),253
egui::Align2::LEFT_TOP,254
"Vast renderkader",255
egui::FontId::proportional(12.),256
egui::Color32::YELLOW,257
);258
}259
206
if let Ok(cache) = self.cache.lock() {260
if let Ok(cache) = self.cache.lock() {207
if let Some(error) = &cache.error {261
if let Some(error) = &cache.error {208
diagnostics.push(error.clone());262
diagnostics.push(error.clone());@@ -0,0 +1,2006 @@
1
//! Bounded orthographic production rendering. GPU work runs on the native
2
//! current context, one request per UI update; sampling/filtering/PNG I/O run
3
//! on the worker. The worker never waits for a preview acknowledgment.
4
use crate::{
5
equipment,
6
preview::{self, Mat4, PreviewCharacter, PreviewControls},
7
project::CameraFraming,
8
render_job::{ImageBuf, RenderEvent},
9
};
10
use glow::HasContext;
11
use std::{
12
path::{Path, PathBuf},
13
sync::{
14
atomic::{AtomicBool, Ordering},
15
mpsc, Arc, Mutex,
16
},
17
thread,
18
};
19
20
pub const SCALE: u32 = 8;
21
pub const MAX_HIGHRES: u32 = 2048;
22
pub const PASSES: [&str; 6] = [
23
"highres",
24
"detail",
25
"target-res / diffuse",
26
"depth",
27
"normal",
28
"specular",
29
];
30
pub const NEAR: f32 = 0.05;
31
pub const FAR: f32 = 40.;
32
#[derive(Clone)]
33
pub struct Draw {
34
pub positions: Arc<Vec<[f32; 3]>>,
35
pub normals: Arc<Vec<[f32; 3]>>,
36
pub uvs: Arc<Vec<[f32; 2]>>,
37
pub vertex_colors: Arc<Vec<[f32; 4]>>,
38
pub indices: Vec<u32>,
39
pub color: [f32; 4],
40
pub albedo: Option<Arc<equipment::Texture>>,
41
pub shiny: Option<Arc<equipment::Texture>>,
42
pub specular: f32,
43
pub cutoff: f32,
44
pub flat_normals: bool,
45
}
46
#[derive(Clone)]
47
pub struct Scene {
48
pub draws: Vec<Draw>,
49
pub view: Mat4,
50
pub projection: Mat4,
51
pub width: u32,
52
pub height: u32,
53
}
54
#[derive(Debug)]
55
pub struct Buffers {
56
pub width: u32,
57
pub height: u32,
58
pub diffuse: Vec<u8>,
59
pub normal: Vec<u8>,
60
pub specular: Vec<u8>,
61
pub depth: Vec<f32>,
62
}
63
#[derive(Debug)]
64
pub struct Target {
65
pub diffuse: ImageBuf,
66
pub normal: ImageBuf,
67
pub specular: ImageBuf,
68
pub depth: Vec<u16>,
69
pub depth_preview: ImageBuf,
70
}
71
#[derive(Clone)]
72
pub struct RenderJob {
73
pub character: PreviewCharacter,
74
pub sex: String,
75
pub preset: Option<equipment::Preset>,
76
pub target_width: u32,
77
pub target_height: u32,
78
pub fps: u32,
79
pub framing: CameraFraming,
80
pub output: PathBuf,
81
}
82
#[derive(Debug, Clone, PartialEq)]
83
pub struct ClipPlan {
84
pub source_name: String,
85
pub token: String,
86
pub frames: u32,
87
}
88
#[derive(Debug, Clone, PartialEq)]
89
pub struct Plan {
90
pub clips: Vec<ClipPlan>,
91
pub images: u32,
92
pub passes: u32,
93
pub files: u32,
94
}
95
pub fn plan(body: &PreviewCharacter, fps: u32) -> Result<Plan, String> {
96
if fps == 0 || fps > 240 {
97
return Err("fps moet 1–240 zijn".into());
98
}
99
let clips = if body.clips.is_empty() {
100
vec![ClipPlan {
101
source_name: String::new(),
102
token: "clip000-bind".into(),
103
frames: 1,
104
}]
105
} else {
106
body.clips
107
.iter()
108
.enumerate()
109
.map(|(i, c)| {
110
let duration = c.duration;
111
if !duration.is_finite() || duration < 0. || duration * fps as f32 > 100000. {
112
return Err("ongeldige of te lange animatie".into());
113
}
114
// Exporter float timestamps commonly put a nominal 1-second
115
// endpoint one ULP above 1. Do not duplicate that terminal pose.
116
let samples = f64::from(duration) * f64::from(fps);
117
let nearest = samples.round();
118
let samples = if (samples - nearest).abs()
119
<= 4. * f64::from(f32::EPSILON) * samples.max(1.)
120
{
121
nearest
122
} else {
123
samples
124
};
125
Ok(ClipPlan {
126
source_name: c.name.clone(),
127
token: clip_token(i, &c.name),
128
frames: samples.ceil().max(1.) as u32,
129
})
130
})
131
.collect::<Result<Vec<_>, String>>()?
132
};
133
let frames = clips
134
.iter()
135
.try_fold(0u32, |n, c| n.checked_add(c.frames))
136
.ok_or("te veel frames")?;
137
let images = frames.checked_mul(36).ok_or("te veel beelden")?;
138
let passes = images.checked_mul(6).ok_or("te veel passes")?;
139
let files = images.checked_mul(4).ok_or("te veel bestanden")?;
140
if files > 500000 {
141
return Err("batch groter dan 500000 uitvoerbestanden".into());
142
}
143
Ok(Plan {
144
clips,
145
images,
146
passes,
147
files,
148
})
149
}
150
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151
pub struct Sample {
152
pub clip: usize,
153
pub frame: u32,
154
pub pitch: i32,
155
pub yaw: u32,
156
}
157
pub fn samples(plan: &Plan) -> impl Iterator<Item = Sample> + '_ {
158
plan.clips.iter().enumerate().flat_map(|(clip, c)| {
159
(0..c.frames).flat_map(move |frame| {
160
[-30, 0, 30].into_iter().flat_map(move |pitch| {
161
(0..12).map(move |y| Sample {
162
clip,
163
frame,
164
pitch,
165
yaw: y * 30,
166
})
167
})
168
})
169
})
170
}
171
172
pub fn clip_token(index: usize, name: &str) -> String {
173
let slug: String = name
174
.chars()
175
.map(|c| {
176
if c.is_ascii_alphanumeric() {
177
c.to_ascii_lowercase()
178
} else {
179
'-'
180
}
181
})
182
.take(48)
183
.collect();
184
format!(
185
"clip{index:03}-{}",
186
if slug.is_empty() { "animation" } else { &slug }
187
)
188
}
189
pub fn output_path(
190
sex: &str,
191
set: &str,
192
clip: &str,
193
frame: u32,
194
pitch: i32,
195
yaw: u32,
196
map: &str,
197
) -> Result<PathBuf, String> {
198
if !["male", "female"].contains(&sex)
199
|| !(set == "body" || equipment::valid_name(set))
200
|| !equipment::valid_name(clip)
201
|| ![-30, 0, 30].contains(&pitch)
202
|| yaw >= 360
203
|| !yaw.is_multiple_of(30)
204
|| !["diffuse", "normal", "depth", "specular"].contains(&map)
205
{
206
return Err("ongeldig uitvoerpad/token".into());
207
}
208
Ok(PathBuf::from(sex)
209
.join(set)
210
.join(clip)
211
.join(format!("f{frame:06}"))
212
.join(format!("p{pitch:+03}_y{yaw:03}_{map}.png")))
213
}
214
pub fn camera(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (Mat4, Mat4) {
215
let target = [0., framing.center_y_milli as f32 / 1000., 0.];
216
let d = preview::eye_offset(yaw, pitch, 10.);
217
let eye = [d[0], target[1] + d[1], d[2]];
218
let half = framing.height_milli as f32 / 2000.;
219
let mut p = preview::identity();
220
p[0] = 1. / (half * aspect);
221
p[5] = 1. / half;
222
p[10] = -2. / (FAR - NEAR);
223
p[14] = -(FAR + NEAR) / (FAR - NEAR);
224
(preview::view_matrix(eye, target), p)
225
}
226
fn point(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
227
std::array::from_fn(|i| m[i] * p[0] + m[i + 4] * p[1] + m[i + 8] * p[2] + m[i + 12])
228
}
229
fn normalize(v: [f32; 3]) -> [f32; 3] {
230
let l = v.iter().map(|x| x * x).sum::<f32>().sqrt();
231
if l > 1e-10 {
232
v.map(|x| x / l)
233
} else {
234
[0., 0., 1.]
235
}
236
}
237
fn normal(m: &Mat4, n: [f32; 3]) -> [f32; 3] {
238
let a = [m[0], m[1], m[2]];
239
let b = [m[4], m[5], m[6]];
240
let c = [m[8], m[9], m[10]];
241
let cross = |a: [f32; 3], b: [f32; 3]| {
242
[
243
a[1] * b[2] - a[2] * b[1],
244
a[2] * b[0] - a[0] * b[2],
245
a[0] * b[1] - a[1] * b[0],
246
]
247
};
248
let x = cross(b, c);
249
let y = cross(c, a);
250
let z = cross(a, b);
251
let det = a.iter().zip(x).map(|(a, b)| a * b).sum::<f32>();
252
if det.abs() < 1e-12 {
253
return normalize(n);
254
}
255
normalize(std::array::from_fn(|i| {
256
(x[i] * n[0] + y[i] * n[1] + z[i] * n[2]) / det
257
}))
258
}
259
pub fn scene(
260
job: &RenderJob,
261
clip: &str,
262
time: f32,
263
pitch: f32,
264
yaw: f32,
265
cache: &mut equipment::TextureCache,
266
) -> Result<Scene, String> {
267
let body = &job.character;
268
let mut draws = Vec::new();
269
if let Some(preset) = &job.preset {
270
let controls = PreviewControls {
271
clip_name: clip.into(),
272
time,
273
..Default::default()
274
};
275
for layer in equipment::layers_cached(
276
preset,
277
usize::from(job.sex == "female"),
278
body,
279
&controls,
280
cache,
281
)? {
282
let m = layer.model;
283
draws.push(Draw {
284
positions: Arc::new(layer.mesh.positions.iter().map(|p| point(&m, *p)).collect()),
285
normals: Arc::new(layer.mesh.normals.iter().map(|n| normal(&m, *n)).collect()),
286
uvs: Arc::new(
287
layer
288
.mesh
289
.uvs
290
.into_iter()
291
.map(|[u, v]| [u, 1. - v])
292
.collect(),
293
),
294
vertex_colors: Arc::new(Vec::new()),
295
indices: layer.mesh.indices,
296
color: layer.mesh.color,
297
albedo: layer.albedo,
298
shiny: layer.shiny,
299
specular: layer.specular,
300
cutoff: 0.5,
301
flat_normals: false,
302
});
303
}
304
} else {
305
let bones = preview::sample_skin_matrices(body, clip, time);
306
let mut positions = Vec::with_capacity(body.positions.len());
307
let mut normals = Vec::with_capacity(body.positions.len());
308
for (i, p) in body.positions.iter().enumerate() {
309
let mut m = preview::identity();
310
if !bones.is_empty() && body.weights.len() == body.positions.len() {
311
m = [0.; 16];
312
let joints = body.joints.get(i).copied().unwrap_or([0; 4]);
313
for (j, w) in joints.into_iter().zip(body.weights[i]) {
314
let b = bones.get(j as usize).ok_or("ongeldige skinjoint")?;
315
for k in 0..16 {
316
m[k] += b[k] * w;
317
}
318
}
319
}
320
positions.push(point(&m, *p));
321
normals.push(normal(
322
&m,
323
body.normals.get(i).copied().unwrap_or([0., 1., 0.]),
324
));
325
}
326
let positions = Arc::new(positions);
327
let normals = Arc::new(normals);
328
let uvs = Arc::new(body.uvs.clone());
329
let colors = Arc::new(body.colors.clone());
330
if body.surfaces.is_empty() {
331
draws.push(Draw {
332
positions,
333
normals,
334
uvs,
335
vertex_colors: colors,
336
indices: body.indices.clone(),
337
color: body.base_color,
338
albedo: None,
339
shiny: None,
340
specular: 0.,
341
cutoff: 0.001,
342
flat_normals: !body.source_normals_complete,
343
});
344
} else {
345
for surface in &body.surfaces {
346
draws.push(Draw {
347
positions: positions.clone(),
348
normals: normals.clone(),
349
uvs: uvs.clone(),
350
vertex_colors: colors.clone(),
351
indices: body.indices[surface.first..surface.first + surface.count].to_vec(),
352
color: surface.color,
353
albedo: surface.albedo.clone(),
354
shiny: None,
355
specular: 0.,
356
cutoff: surface.cutoff,
357
flat_normals: surface.flat_normals,
358
});
359
}
360
}
361
}
362
let (view, projection) = camera(
363
job.framing,
364
yaw,
365
pitch,
366
job.target_width as f32 / job.target_height as f32,
367
);
368
Ok(Scene {
369
draws,
370
view,
371
projection,
372
width: job.target_width * SCALE,
373
height: job.target_height * SCALE,
374
})
375
}
376
377
impl Scene {
378
pub fn validate(&self) -> Result<(), String> {
379
if self.width == 0
380
|| self.height == 0
381
|| self.width > MAX_HIGHRES
382
|| self.height > MAX_HIGHRES
383
|| self.draws.len() > 4096
384
|| self
385
.view
386
.iter()
387
.chain(&self.projection)
388
.any(|v| !v.is_finite())
389
{
390
return Err("ongeldige GPU-scène/afmetingen".into());
391
}
392
for d in &self.draws {
393
if d.positions.len() > 1_000_000
394
|| d.positions.len() != d.normals.len()
395
|| d.indices.len() > 3_000_000
396
|| !d.indices.len().is_multiple_of(3)
397
|| d.indices.iter().any(|i| *i as usize >= d.positions.len())
398
|| d.positions
399
.iter()
400
.chain(d.normals.iter())
401
.flatten()
402
.chain(d.uvs.iter().flatten())
403
.chain(d.vertex_colors.iter().flatten())
404
.chain(d.color.iter())
405
.any(|v| !v.is_finite())
406
|| !d.specular.is_finite()
407
|| !d.cutoff.is_finite()
408
{
409
return Err("ongeldige of te grote GPU-geometrie".into());
410
}
411
for t in [d.albedo.as_ref(), d.shiny.as_ref()].into_iter().flatten() {
412
if t.width == 0
413
|| t.height == 0
414
|| t.width > 2048
415
|| t.height > 2048
416
|| t.rgba.len() != t.width as usize * t.height as usize * 4
417
{
418
return Err("ongeldige GPU-texture".into());
419
}
420
}
421
}
422
Ok(())
423
}
424
}
425
426
#[derive(Default)]
427
pub struct GpuRenderer {
428
program: Option<glow::Program>,
429
vao: Option<glow::VertexArray>,
430
vbo: Option<glow::Buffer>,
431
ebo: Option<glow::Buffer>,
432
fbo: Option<glow::Framebuffer>,
433
colors: Vec<glow::Texture>,
434
depth: Option<glow::Renderbuffer>,
435
extent: [u32; 2],
436
}
437
impl GpuRenderer {
438
/// # Safety
439
/// A native OpenGL 3.3 context must be current on this thread.
440
pub unsafe fn destroy(&mut self, gl: &glow::Context) {
441
self.destroy_target(gl);
442
if let Some(x) = self.program.take() {
443
gl.delete_program(x);
444
}
445
if let Some(x) = self.vao.take() {
446
gl.delete_vertex_array(x);
447
}
448
if let Some(x) = self.vbo.take() {
449
gl.delete_buffer(x);
450
}
451
if let Some(x) = self.ebo.take() {
452
gl.delete_buffer(x);
453
}
454
}
455
unsafe fn destroy_target(&mut self, gl: &glow::Context) {
456
for x in self.colors.drain(..) {
457
gl.delete_texture(x);
458
}
459
if let Some(x) = self.depth.take() {
460
gl.delete_renderbuffer(x);
461
}
462
if let Some(x) = self.fbo.take() {
463
gl.delete_framebuffer(x);
464
}
465
self.extent = [0, 0];
466
}
467
unsafe fn init(&mut self, gl: &glow::Context) -> Result<(), String> {
468
if self.program.is_some() {
469
return Ok(());
470
}
471
let p = gl.create_program()?;
472
let mut shaders = Vec::new();
473
for (kind, source) in [
474
(glow::VERTEX_SHADER, VERTEX),
475
(glow::FRAGMENT_SHADER, FRAGMENT),
476
] {
477
let shader = gl.create_shader(kind)?;
478
gl.shader_source(shader, source);
479
gl.compile_shader(shader);
480
if !gl.get_shader_compile_status(shader) {
481
let error = gl.get_shader_info_log(shader);
482
gl.delete_shader(shader);
483
for s in shaders {
484
gl.delete_shader(s);
485
}
486
gl.delete_program(p);
487
return Err(error);
488
}
489
gl.attach_shader(p, shader);
490
shaders.push(shader);
491
}
492
gl.link_program(p);
493
let result = if gl.get_program_link_status(p) {
494
Ok(())
495
} else {
496
Err(gl.get_program_info_log(p))
497
};
498
for s in shaders {
499
gl.detach_shader(p, s);
500
gl.delete_shader(s);
501
}
502
if let Err(e) = result {
503
gl.delete_program(p);
504
return Err(e);
505
}
506
self.program = Some(p);
507
self.vao = Some(gl.create_vertex_array()?);
508
self.vbo = Some(gl.create_buffer()?);
509
self.ebo = Some(gl.create_buffer()?);
510
Ok(())
511
}
512
unsafe fn target(&mut self, gl: &glow::Context, w: u32, h: u32) -> Result<(), String> {
513
if self.extent == [w, h] {
514
return Ok(());
515
}
516
self.destroy_target(gl);
517
self.fbo = Some(gl.create_framebuffer()?);
518
gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);
519
for slot in 0..3 {
520
let t = gl.create_texture()?;
521
self.colors.push(t);
522
gl.bind_texture(glow::TEXTURE_2D, Some(t));
523
gl.tex_parameter_i32(
524
glow::TEXTURE_2D,
525
glow::TEXTURE_MIN_FILTER,
526
glow::NEAREST as i32,
527
);
528
gl.tex_parameter_i32(
529
glow::TEXTURE_2D,
530
glow::TEXTURE_MAG_FILTER,
531
glow::NEAREST as i32,
532
);
533
gl.tex_image_2d(
534
glow::TEXTURE_2D,
535
0,
536
glow::RGBA8 as i32,
537
w as i32,
538
h as i32,
539
0,
540
glow::RGBA,
541
glow::UNSIGNED_BYTE,
542
None,
543
);
544
gl.framebuffer_texture_2d(
545
glow::FRAMEBUFFER,
546
glow::COLOR_ATTACHMENT0 + slot,
547
glow::TEXTURE_2D,
548
Some(t),
549
0,
550
);
551
}
552
let d = gl.create_renderbuffer()?;
553
self.depth = Some(d);
554
gl.bind_renderbuffer(glow::RENDERBUFFER, Some(d));
555
gl.renderbuffer_storage(
556
glow::RENDERBUFFER,
557
glow::DEPTH_COMPONENT24,
558
w as i32,
559
h as i32,
560
);
561
gl.framebuffer_renderbuffer(
562
glow::FRAMEBUFFER,
563
glow::DEPTH_ATTACHMENT,
564
glow::RENDERBUFFER,
565
Some(d),
566
);
567
gl.draw_buffers(&[
568
glow::COLOR_ATTACHMENT0,
569
glow::COLOR_ATTACHMENT1,
570
glow::COLOR_ATTACHMENT2,
571
]);
572
if gl.check_framebuffer_status(glow::FRAMEBUFFER) != glow::FRAMEBUFFER_COMPLETE {
573
return Err("productie-FBO onvolledig".into());
574
}
575
self.extent = [w, h];
576
Ok(())
577
}
578
/// Render and read back unlit color, view-space normals, scalar specular and
579
/// the actual depth attachment. All four use the same winning surface.
580
/// # Safety
581
/// `gl` is current on the UI thread. GL state is reset for egui painting.
582
pub unsafe fn render(&mut self, gl: &glow::Context, scene: &Scene) -> Result<Buffers, String> {
583
if scene.width == 0
584
|| scene.height == 0
585
|| scene.width > MAX_HIGHRES
586
|| scene.height > MAX_HIGHRES
587
{
588
return Err("highres moet 1–2048 pixels zijn (8× target)".into());
589
}
590
scene.validate()?;
591
let previous = gl.get_parameter_i32(glow::FRAMEBUFFER_BINDING);
592
let mut vp = [0; 4];
593
gl.get_parameter_i32_slice(glow::VIEWPORT, &mut vp);
594
let result = (|| {
595
self.init(gl)?;
596
self.target(gl, scene.width, scene.height)?;
597
gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);
598
gl.draw_buffers(&[
599
glow::COLOR_ATTACHMENT0,
600
glow::COLOR_ATTACHMENT1,
601
glow::COLOR_ATTACHMENT2,
602
]);
603
gl.viewport(0, 0, scene.width as i32, scene.height as i32);
604
gl.disable(glow::SCISSOR_TEST);
605
gl.disable(glow::CULL_FACE);
606
gl.disable(glow::BLEND);
607
gl.enable(glow::DEPTH_TEST);
608
gl.depth_mask(true);
609
gl.depth_func(glow::LESS);
610
gl.clear_depth_f32(1.);
611
gl.clear_color(0., 0., 0., 0.);
612
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
613
let p = self.program.ok_or("shader ontbreekt")?;
614
gl.use_program(Some(p));
615
gl.uniform_matrix_4_f32_slice(
616
gl.get_uniform_location(p, "u_view").as_ref(),
617
false,
618
&scene.view,
619
);
620
gl.uniform_matrix_4_f32_slice(
621
gl.get_uniform_location(p, "u_projection").as_ref(),
622
false,
623
&scene.projection,
624
);
625
gl.bind_vertex_array(self.vao);
626
gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);
627
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo);
628
for (slot, size, offset) in [(0, 3, 0), (1, 3, 3), (2, 2, 6), (3, 4, 8)] {
629
gl.enable_vertex_attrib_array(slot);
630
gl.vertex_attrib_pointer_f32(slot, size, glow::FLOAT, false, 12 * 4, offset * 4);
631
}
632
for draw in &scene.draws {
633
if draw.positions.len() != draw.normals.len()
634
|| draw
635
.indices
636
.iter()
637
.any(|i| *i as usize >= draw.positions.len())
638
|| draw.indices.len() > i32::MAX as usize
639
{
640
return Err("ongeldige GPU-geometrie".into());
641
}
642
let mut data = Vec::with_capacity(draw.positions.len() * 12);
643
for (i, pos) in draw.positions.iter().enumerate() {
644
data.extend_from_slice(pos);
645
data.extend_from_slice(&draw.normals[i]);
646
data.extend_from_slice(&draw.uvs.get(i).copied().unwrap_or([0.; 2]));
647
data.extend_from_slice(&draw.vertex_colors.get(i).copied().unwrap_or([1.; 4]));
648
}
649
gl.buffer_data_u8_slice(
650
glow::ARRAY_BUFFER,
651
std::slice::from_raw_parts(data.as_ptr().cast(), data.len() * 4),
652
glow::STREAM_DRAW,
653
);
654
gl.buffer_data_u8_slice(
655
glow::ELEMENT_ARRAY_BUFFER,
656
std::slice::from_raw_parts(
657
draw.indices.as_ptr().cast(),
658
draw.indices.len() * 4,
659
),
660
glow::STREAM_DRAW,
661
);
662
gl.uniform_4_f32(
663
gl.get_uniform_location(p, "u_color").as_ref(),
664
draw.color[0],
665
draw.color[1],
666
draw.color[2],
667
draw.color[3],
668
);
669
gl.uniform_1_f32(
670
gl.get_uniform_location(p, "u_specular").as_ref(),
671
draw.specular,
672
);
673
gl.uniform_1_f32(gl.get_uniform_location(p, "u_cutoff").as_ref(), draw.cutoff);
674
gl.uniform_1_i32(
675
gl.get_uniform_location(p, "u_flat").as_ref(),
676
i32::from(draw.flat_normals),
677
);
678
let mut temp = Vec::new();
679
for (unit, name, texture) in [
680
(0, "u_albedo", draw.albedo.as_ref()),
681
(1, "u_shiny", draw.shiny.as_ref()),
682
] {
683
gl.active_texture(glow::TEXTURE0 + unit);
684
gl.uniform_1_i32(gl.get_uniform_location(p, name).as_ref(), unit as i32);
685
gl.uniform_1_i32(
686
gl.get_uniform_location(p, &format!("{name}_enabled"))
687
.as_ref(),
688
i32::from(texture.is_some()),
689
);
690
if let Some(image) = texture {
691
let t = gl.create_texture()?;
692
temp.push(t);
693
gl.bind_texture(glow::TEXTURE_2D, Some(t));
694
gl.tex_parameter_i32(
695
glow::TEXTURE_2D,
696
glow::TEXTURE_MIN_FILTER,
697
glow::NEAREST as i32,
698
);
699
gl.tex_parameter_i32(
700
glow::TEXTURE_2D,
701
glow::TEXTURE_MAG_FILTER,
702
glow::NEAREST as i32,
703
);
704
gl.tex_parameter_i32(
705
glow::TEXTURE_2D,
706
glow::TEXTURE_WRAP_S,
707
glow::REPEAT as i32,
708
);
709
gl.tex_parameter_i32(
710
glow::TEXTURE_2D,
711
glow::TEXTURE_WRAP_T,
712
glow::REPEAT as i32,
713
);
714
gl.tex_image_2d(
715
glow::TEXTURE_2D,
716
0,
717
if unit == 0 {
718
glow::SRGB8_ALPHA8 as i32
719
} else {
720
glow::RGBA8 as i32
721
},
722
image.width as i32,
723
image.height as i32,
724
0,
725
glow::RGBA,
726
glow::UNSIGNED_BYTE,
727
Some(&image.rgba),
728
);
729
} else {
730
gl.bind_texture(glow::TEXTURE_2D, None);
731
}
732
}
733
gl.draw_elements(
734
glow::TRIANGLES,
735
draw.indices.len() as i32,
736
glow::UNSIGNED_INT,
737
0,
738
);
739
for t in temp {
740
gl.delete_texture(t);
741
}
742
}
743
let pixels = scene.width as usize * scene.height as usize;
744
let mut maps = Vec::new();
745
gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1);
746
for slot in 0..3 {
747
let mut data = vec![0; pixels * 4];
748
gl.read_buffer(glow::COLOR_ATTACHMENT0 + slot);
749
gl.read_pixels(
750
0,
751
0,
752
scene.width as i32,
753
scene.height as i32,
754
glow::RGBA,
755
glow::UNSIGNED_BYTE,
756
glow::PixelPackData::Slice(&mut data),
757
);
758
flip_rows(&mut data, scene.width as usize * 4, scene.height as usize);
759
maps.push(data);
760
}
761
let mut depth = vec![1f32; pixels];
762
let bytes = std::slice::from_raw_parts_mut(depth.as_mut_ptr().cast(), pixels * 4);
763
gl.read_pixels(
764
0,
765
0,
766
scene.width as i32,
767
scene.height as i32,
768
glow::DEPTH_COMPONENT,
769
glow::FLOAT,
770
glow::PixelPackData::Slice(bytes),
771
);
772
flip_rows(&mut depth, scene.width as usize, scene.height as usize);
773
let error = gl.get_error();
774
if error != glow::NO_ERROR {
775
return Err(format!("productie-GL fout 0x{error:x}"));
776
}
777
let specular = maps.pop().unwrap();
778
let normal = maps.pop().unwrap();
779
let diffuse = maps.pop().unwrap();
780
Ok(Buffers {
781
width: scene.width,
782
height: scene.height,
783
diffuse,
784
normal,
785
specular,
786
depth,
787
})
788
})();
789
gl.bind_framebuffer(
790
glow::FRAMEBUFFER,
791
std::num::NonZeroU32::new(previous as u32).map(glow::NativeFramebuffer),
792
);
793
gl.viewport(vp[0], vp[1], vp[2], vp[3]);
794
gl.bind_vertex_array(None);
795
gl.use_program(None);
796
gl.active_texture(glow::TEXTURE0);
797
gl.disable(glow::DEPTH_TEST);
798
gl.disable(glow::SCISSOR_TEST);
799
gl.enable(glow::BLEND);
800
gl.pixel_store_i32(glow::PACK_ALIGNMENT, 4);
801
result
802
}
803
}
804
fn flip_rows<T>(data: &mut [T], stride: usize, height: usize) {
805
for y in 0..height / 2 {
806
let split = (height - 1 - y) * stride;
807
let (a, b) = data.split_at_mut(split);
808
a[y * stride..(y + 1) * stride].swap_with_slice(&mut b[..stride]);
809
}
810
}
811
const VERTEX: &str = r#"#version 330
812
layout(location=0)in vec3 a_position;layout(location=1)in vec3 a_normal;layout(location=2)in vec2 a_uv;layout(location=3)in vec4 a_color;
813
uniform mat4 u_view;uniform mat4 u_projection;out vec3 v_normal;out vec3 v_view_position;out vec2 v_uv;out vec4 v_color;
814
void main(){v_color=a_color;v_normal=mat3(u_view)*a_normal;v_uv=a_uv;vec4 position=u_view*vec4(a_position,1.0);v_view_position=position.xyz;gl_Position=u_projection*position;}
815
"#;
816
const FRAGMENT: &str = r#"#version 330
817
in vec3 v_normal;in vec3 v_view_position;uniform bool u_flat;in vec2 v_uv;in vec4 v_color;uniform vec4 u_color;uniform float u_cutoff;uniform vec3 u_light_direction; // deliberately unused: production maps are unlit
818
uniform float u_specular;
819
uniform sampler2D u_albedo;uniform sampler2D u_shiny;uniform bool u_albedo_enabled;uniform bool u_shiny_enabled;
820
layout(location=0)out vec4 diffuse;layout(location=1)out vec4 normal;layout(location=2)out vec4 specular;
821
vec3 encode_srgb(vec3 x){return mix(x*12.92,1.055*pow(max(x,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),x));}
822
void main(){vec3 n=normalize(v_normal);if(u_flat){n=normalize(cross(dFdx(v_view_position),dFdy(v_view_position)));if(!gl_FrontFacing)n=-n;}vec4 c=u_color*v_color;if(u_albedo_enabled)c*=texture(u_albedo,v_uv);if(c.a<u_cutoff)discard;float s=u_specular;if(u_shiny_enabled)s*=dot(texture(u_shiny,v_uv).rgb,vec3(0.2126,0.7152,0.0722));diffuse=vec4(encode_srgb(clamp(c.rgb,0.0,1.0)),c.a);normal=vec4(n*0.5+0.5,c.a);specular=vec4(vec3(clamp(s,0.0,1.0)),c.a);}
823
"#;
824
825
fn image(w: u32, h: u32, rgba: Vec<u8>) -> ImageBuf {
826
ImageBuf {
827
width: w,
828
height: h,
829
rgba,
830
}
831
}
832
fn linear(x: u8) -> f32 {
833
static LUT: std::sync::OnceLock<[f32; 256]> = std::sync::OnceLock::new();
834
LUT.get_or_init(|| {
835
std::array::from_fn(|i| {
836
let v = i as f32 / 255.;
837
if v <= 0.04045 {
838
v / 12.92
839
} else {
840
((v + 0.055) / 1.055).powf(2.4)
841
}
842
})
843
})[x as usize]
844
}
845
846
fn srgb(x: f32) -> u8 {
847
let v = if x <= 0.0031308 {
848
x * 12.92
849
} else {
850
1.055 * x.powf(1. / 2.4) - 0.055
851
};
852
(v * 255.).round().clamp(0., 255.) as u8
853
}
854
impl Buffers {
855
pub fn validate(&self) -> Result<(), String> {
856
let pixels = (self.width as usize)
857
.checked_mul(self.height as usize)
858
.ok_or("te veel pixels")?;
859
if self.width == 0
860
|| self.height == 0
861
|| self.width > MAX_HIGHRES
862
|| self.height > MAX_HIGHRES
863
|| self.diffuse.len() != pixels * 4
864
|| self.normal.len() != pixels * 4
865
|| self.specular.len() != pixels * 4
866
|| self.depth.len() != pixels
867
|| self
868
.depth
869
.iter()
870
.any(|d| !d.is_finite() || !(0.0..=1.0).contains(d))
871
{
872
return Err("ongeldige GPU-mapafmetingen/data".into());
873
}
874
Ok(())
875
}
876
}
877
pub fn detail_map(raw: &Buffers) -> Result<Vec<u8>, String> {
878
raw.validate()?;
879
let w = raw.width as usize;
880
let h = raw.height as usize;
881
let mut out = vec![0; w * h];
882
for y in 0..h {
883
for x in 0..w {
884
let i = y * w + x;
885
let mut edge = 0f32;
886
for (nx, ny) in [
887
(x.saturating_sub(1), y),
888
((x + 1).min(w - 1), y),
889
(x, y.saturating_sub(1)),
890
(x, (y + 1).min(h - 1)),
891
] {
892
let j = ny * w + nx;
893
let a = raw.diffuse[i * 4 + 3] as f32 / 255.;
894
let b = raw.diffuse[j * 4 + 3] as f32 / 255.;
895
edge = edge.max((a - b).abs());
896
if a > 0. && b > 0. {
897
for c in 0..3 {
898
edge = edge.max(
899
(linear(raw.diffuse[i * 4 + c]) - linear(raw.diffuse[j * 4 + c])).abs(),
900
);
901
edge = edge.max(
902
(raw.normal[i * 4 + c] as f32 - raw.normal[j * 4 + c] as f32).abs()
903
/ 255.,
904
);
905
}
906
edge = edge.max(((raw.depth[i] - raw.depth[j]).abs() * 200.).min(1.));
907
}
908
}
909
out[i] = (edge * 255.).round().clamp(0., 255.) as u8;
910
}
911
}
912
Ok(out)
913
}
914
pub fn detail_image(raw: &Buffers, detail: &[u8]) -> ImageBuf {
915
let mut rgba = Vec::with_capacity(detail.len() * 4);
916
for (i, v) in detail.iter().enumerate() {
917
rgba.extend_from_slice(&[*v, *v, *v, raw.diffuse[i * 4 + 3]]);
918
}
919
image(raw.width, raw.height, rgba)
920
}
921
pub fn resolve(raw: &Buffers, detail: &[u8], w: u32, h: u32) -> Result<Target, String> {
922
raw.validate()?;
923
if w == 0 || h == 0 || w > MAX_HIGHRES / SCALE || h > MAX_HIGHRES / SCALE {
924
return Err("ongeldige target-afmetingen".into());
925
}
926
if raw.width != w * SCALE
927
|| raw.height != h * SCALE
928
|| detail.len() != raw.width as usize * raw.height as usize
929
{
930
return Err("ongeldige 8× resolve-afmetingen".into());
931
}
932
let count = w as usize * h as usize;
933
let mut diffuse = vec![0; count * 4];
934
let mut normals = diffuse.clone();
935
let mut specular = diffuse.clone();
936
let mut depth = vec![0u16; count * 2];
937
let mut depth_preview = diffuse.clone();
938
for y in 0..h {
939
for x in 0..w {
940
let i = (y * w + x) as usize;
941
let mut closest = 1f32;
942
let mut coverage = 0f32;
943
for dy in 0..SCALE {
944
for dx in 0..SCALE {
945
let j = ((y * SCALE + dy) * raw.width + x * SCALE + dx) as usize;
946
let a = raw.diffuse[j * 4 + 3] as f32 / 255.;
947
coverage += a;
948
if a > 0. && raw.depth[j].is_finite() {
949
closest = closest.min(raw.depth[j]);
950
}
951
}
952
}
953
let alpha = (coverage / (SCALE * SCALE) as f32 * 255.)
954
.round()
955
.clamp(0., 255.) as u8;
956
if alpha == 0 {
957
continue;
958
}
959
let mut color = [0.; 3];
960
let mut n = [0.; 3];
961
let mut shiny = 0.;
962
let mut sum = 0.;
963
// One coherent front-surface cluster; never average foreground with a rear
964
// surface. Edges get extra weight, and any thin covered feature survives.
965
for dy in 0..SCALE {
966
for dx in 0..SCALE {
967
let j = ((y * SCALE + dy) * raw.width + x * SCALE + dx) as usize;
968
let a = raw.diffuse[j * 4 + 3] as f32 / 255.;
969
if a == 0. || raw.depth[j] > closest + 0.0025 {
970
continue;
971
}
972
let weight = a * (1. + 4. * detail[j] as f32 / 255.);
973
sum += weight;
974
for c in 0..3 {
975
color[c] += linear(raw.diffuse[j * 4 + c]) * weight;
976
n[c] += (raw.normal[j * 4 + c] as f32 / 255. * 2. - 1.) * weight;
977
}
978
shiny += raw.specular[j * 4] as f32 * weight;
979
}
980
}
981
if sum <= 0. {
982
return Err("ongeldige depth/coverage in GPU-resultaat".into());
983
}
984
n = normalize(n);
985
let d = (closest.clamp(0., 1.) * 65535.).round() as u16;
986
for c in 0..3 {
987
diffuse[i * 4 + c] = srgb(color[c] / sum);
988
normals[i * 4 + c] = ((n[c] * 0.5 + 0.5) * 255.).round().clamp(0., 255.) as u8;
989
specular[i * 4 + c] = (shiny / sum).round().clamp(0., 255.) as u8;
990
depth_preview[i * 4 + c] = (d >> 8) as u8;
991
}
992
for map in [
993
&mut diffuse,
994
&mut normals,
995
&mut specular,
996
&mut depth_preview,
997
] {
998
map[i * 4 + 3] = alpha;
999
}
1000
depth[i * 2] = d;
1001
depth[i * 2 + 1] = u16::from(alpha) * 257;
1002
}
1003
}
1004
Ok(Target {
1005
diffuse: image(w, h, diffuse),
1006
normal: image(w, h, normals),
1007
specular: image(w, h, specular),
1008
depth,
1009
depth_preview: image(w, h, depth_preview),
1010
})
1011
}
1012
1013
fn safe_parent(root: &Path, relative: &Path) -> Result<PathBuf, String> {
1014
let relative = relative
1015
.to_str()
1016
.ok_or("ongeldig uitvoerpad")?
1017
.replace('\\', "/");
1018
let relative = crate::project::portable_relative(&relative)?;
1019
let relative = relative.as_str();
1020
let canonical = root.canonicalize().map_err(|e| e.to_string())?;
1021
let path = root.join(relative);
1022
let parent = path.parent().ok_or("uitvoermap ontbreekt")?;
1023
let mut prefix = root.to_path_buf();
1024
for part in Path::new(relative)
1025
.parent()
1026
.unwrap_or(Path::new(""))
1027
.components()
1028
{
1029
prefix.push(part);
1030
if !prefix.exists() {
1031
std::fs::create_dir(&prefix).map_err(|e| e.to_string())?;
1032
}
1033
let real = prefix.canonicalize().map_err(|e| e.to_string())?;
1034
if !real.starts_with(&canonical) {
1035
return Err("uitvoer verlaat projectmap".into());
1036
}
1037
}
1038
if !parent
1039
.canonicalize()
1040
.map_err(|e| e.to_string())?
1041
.starts_with(canonical)
1042
{
1043
return Err("onveilig uitvoerpad".into());
1044
}
1045
Ok(path)
1046
}
1047
fn atomic_file(
1048
root: &Path,
1049
relative: &Path,
1050
write: impl FnOnce(&mut std::fs::File) -> Result<(), String>,
1051
) -> Result<(), String> {
1052
let path = safe_parent(root, relative)?;
1053
let temp = path.with_extension(format!("part-{}", std::process::id()));
1054
let mut owned = false;
1055
let result = (|| {
1056
let mut file = std::fs::OpenOptions::new()
1057
.write(true)
1058
.create_new(true)
1059
.open(&temp)
1060
.map_err(|e| e.to_string())?;
1061
owned = true;
1062
write(&mut file)?;
1063
file.sync_all().map_err(|e| e.to_string())?;
1064
std::fs::hard_link(&temp, &path).map_err(|e| e.to_string())?;
1065
Ok(())
1066
})();
1067
if owned {
1068
let _ = std::fs::remove_file(temp);
1069
}
1070
result
1071
}
1072
pub fn write_rgba(root: &Path, relative: &Path, img: &ImageBuf) -> Result<(), String> {
1073
if img.width == 0
1074
|| img.height == 0
1075
|| img.width > MAX_HIGHRES / SCALE
1076
|| img.height > MAX_HIGHRES / SCALE
1077
|| img.rgba.len() != img.width as usize * img.height as usize * 4
1078
{
1079
return Err("ongeldige PNG-afmetingen".into());
1080
}
1081
atomic_file(root, relative, |file| {
1082
let mut encoder = png::Encoder::new(file, img.width, img.height);
1083
encoder.set_color(png::ColorType::Rgba);
1084
encoder.set_depth(png::BitDepth::Eight);
1085
if relative
1086
.file_name()
1087
.and_then(|p| p.to_str())
1088
.map(|p| p.ends_with("_diffuse.png"))
1089
.unwrap_or(false)
1090
{
1091
encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
1092
}
1093
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
1094
writer
1095
.write_image_data(&img.rgba)
1096
.map_err(|e| e.to_string())?;
1097
writer.finish().map_err(|e| e.to_string())
1098
})
1099
}
1100
pub fn write_depth(
1101
root: &Path,
1102
relative: &Path,
1103
w: u32,
1104
h: u32,
1105
depth: &[u16],
1106
) -> Result<(), String> {
1107
if w == 0
1108
|| h == 0
1109
|| w > MAX_HIGHRES / SCALE
1110
|| h > MAX_HIGHRES / SCALE
1111
|| depth.len() != w as usize * h as usize * 2
1112
{
1113
return Err("ongeldige 16-bit depthafmetingen".into());
1114
}
1115
let bytes: Vec<u8> = depth.iter().flat_map(|x| x.to_be_bytes()).collect();
1116
atomic_file(root, relative, |file| {
1117
let mut encoder = png::Encoder::new(file, w, h);
1118
encoder.set_color(png::ColorType::GrayscaleAlpha);
1119
encoder.set_depth(png::BitDepth::Sixteen);
1120
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
1121
writer.write_image_data(&bytes).map_err(|e| e.to_string())?;
1122
writer.finish().map_err(|e| e.to_string())
1123
})
1124
}
1125
fn write_json(root: &Path, name: &str, value: serde_json::Value) -> Result<(), String> {
1126
use std::io::Write;
1127
let data = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?;
1128
atomic_file(root, Path::new(name), |f| {
1129
f.write_all(&data).map_err(|e| e.to_string())
1130
})
1131
}
1132
1133
pub struct GpuRequest {
1134
pub scene: Scene,
1135
reply: mpsc::SyncSender<Result<Buffers, String>>,
1136
}
1137
impl GpuRequest {
1138
pub fn fail(self, error: String) {
1139
let _ = self.reply.send(Err(error));
1140
}
1141
/// # Safety
1142
/// `gl` must be current on this UI thread.
1143
pub unsafe fn service(self, renderer: &mut GpuRenderer, gl: &glow::Context) {
1144
let result = renderer.render(gl, &self.scene);
1145
let _ = self.reply.send(result);
1146
}
1147
}
1148
#[derive(Default)]
1149
struct Pending {
1150
pass: Option<RenderEvent>,
1151
progress: Option<RenderEvent>,
1152
terminal: Option<RenderEvent>,
1153
}
1154
#[derive(Clone, Default)]
1155
pub struct Mailbox(Arc<Mutex<Pending>>);
1156
impl Mailbox {
1157
pub fn publish(&self, event: RenderEvent) {
1158
if let Ok(mut state) = self.0.lock() {
1159
match event {
1160
RenderEvent::Pass { .. } => {
1161
state.pass = Some(event);
1162
state.progress = None;
1163
}
1164
RenderEvent::Step { .. } => state.progress = Some(event),
1165
_ => state.terminal = Some(event),
1166
}
1167
}
1168
}
1169
pub fn drain(&self) -> Vec<RenderEvent> {
1170
let mut events = Vec::new();
1171
if let Ok(mut s) = self.0.lock() {
1172
events.extend(s.pass.take());
1173
events.extend(s.progress.take());
1174
events.extend(s.terminal.take());
1175
}
1176
events
1177
}
1178
}
1179
pub struct RenderThread {
1180
pub mailbox: Mailbox,
1181
pub gpu: mpsc::Receiver<GpuRequest>,
1182
pub cancel: Arc<AtomicBool>,
1183
pub handle: Option<thread::JoinHandle<()>>,
1184
}
1185
impl Drop for RenderThread {
1186
fn drop(&mut self) {
1187
self.cancel.store(true, Ordering::Relaxed);
1188
}
1189
}
1190
pub fn spawn(job: RenderJob, ctx: Option<eframe::egui::Context>) -> RenderThread {
1191
let mailbox = Mailbox::default();
1192
let output = mailbox.clone();
1193
let cancel = Arc::new(AtomicBool::new(false));
1194
let flag = cancel.clone();
1195
let (tx, gpu) = mpsc::channel();
1196
let handle = thread::spawn(move || {
1197
let mut emit = |event| {
1198
output.publish(event);
1199
if let Some(ctx) = &ctx {
1200
ctx.request_repaint();
1201
}
1202
};
1203
let result = execute(job, &tx, &flag, ctx.as_ref(), &mut emit);
1204
if let Err(error) = result {
1205
if flag.load(Ordering::Relaxed) {
1206
emit(RenderEvent::Cancelled);
1207
} else {
1208
emit(RenderEvent::Failed(error));
1209
}
1210
}
1211
});
1212
RenderThread {
1213
mailbox,
1214
gpu,
1215
cancel,
1216
handle: Some(handle),
1217
}
1218
}
1219
fn execute(
1220
mut job: RenderJob,
1221
gpu: &mpsc::Sender<GpuRequest>,
1222
cancel: &AtomicBool,
1223
ctx: Option<&eframe::egui::Context>,
1224
emit: &mut dyn FnMut(RenderEvent),
1225
) -> Result<(), String> {
1226
job.framing.validate()?;
1227
if job.target_width == 0
1228
|| job.target_height == 0
1229
|| job.target_width > MAX_HIGHRES / SCALE
1230
|| job.target_height > MAX_HIGHRES / SCALE
1231
{
1232
return Err("8× highres vereist targetafmetingen 1–256".into());
1233
}
1234
if !["male", "female"].contains(&job.sex.as_str()) {
1235
return Err("ongeldig geslacht".into());
1236
}
1237
if let Some(p) = &job.preset {
1238
p.validate()?;
1239
if !p.applies[usize::from(job.sex == "female")] {
1240
return Err("equipment niet geldig voor dit geslacht".into());
1241
}
1242
if p.attachments.is_empty() {
1243
return Err("equipment heeft geen onderdelen".into());
1244
}
1245
}
1246
let plan = plan(&job.character, job.fps)?;
1247
let output_bytes =
1248
u64::from(plan.images) * u64::from(job.target_width) * u64::from(job.target_height) * 16;
1249
if output_bytes > 16 * 1024 * 1024 * 1024 {
1250
return Err("batch groter dan 16 GiB ongecomprimeerde targetmaps".into());
1251
}
1252
std::fs::create_dir_all(&job.output).map_err(|e| e.to_string())?;
1253
let stamp = std::time::SystemTime::now()
1254
.duration_since(std::time::UNIX_EPOCH)
1255
.map_err(|e| e.to_string())?
1256
.as_nanos();
1257
let root = job
1258
.output
1259
.join(format!("run-{stamp}-{}", std::process::id()));
1260
std::fs::create_dir(&root).map_err(|e| e.to_string())?;
1261
let set = job
1262
.preset
1263
.as_ref()
1264
.map(|p| p.name.as_str())
1265
.unwrap_or("body")
1266
.to_string();
1267
write_json(
1268
&root,
1269
"render.json",
1270
serde_json::json!({"format_version":1,"status":"incomplete","sex":job.sex,"set":set,"target":[job.target_width,job.target_height],"fps":job.fps,"highres_scale":SCALE,"framing":job.framing,"near":NEAR,"far":FAR,"normal_space":"view (+Y up), PNG rows top-down","camera_distance":10,"yaw_zero":"camera on -Z","pitches":[-30,0,30],"yaw_step":30,"sampling":"clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained","alpha":"straight, opaque/cutout, coverage resolve","diffuse":"unlit sRGB albedo × vertex color, linear-light detail resolve","depth":"16-bit gray+alpha; distance=near+(sample/65535)*(far-near)","specular":"linear scalar; body defaults zero","pass_order":PASSES,"saved_maps":["diffuse","depth","normal","specular"],"clips":plan.clips.iter().enumerate().map(|(i,c)|serde_json::json!({"name":c.source_name,"token":c.token,"frames":c.frames,"duration":job.character.clips.get(i).map(|c|c.duration).unwrap_or(0.)})).collect::<Vec<_>>(),"images":plan.images,"passes":plan.passes,"png_files":plan.files}),
1271
)?;
1272
// Internal unique names select every source clip, even duplicate display names.
1273
for (i, c) in job.character.clips.iter_mut().enumerate() {
1274
c.name = format!("render-clip-{i}");
1275
}
1276
let mut completed = 0u32;
1277
let mut cache = equipment::TextureCache::default();
1278
let result = (|| {
1279
for sample in samples(&plan) {
1280
let clip = &plan.clips[sample.clip];
1281
let frame = sample.frame;
1282
let pitch = sample.pitch;
1283
let yaw = sample.yaw;
1284
let key = if job.character.clips.is_empty() {
1285
String::new()
1286
} else {
1287
format!("render-clip-{}", sample.clip)
1288
};
1289
if cancel.load(Ordering::Relaxed) {
1290
return Err("geannuleerd".into());
1291
}
1292
let pose = format!(
1293
"{} {set} · {} f{frame}/{} · p{pitch} y{yaw}",
1294
job.sex, clip.source_name, clip.frames
1295
);
1296
emit(RenderEvent::Step {
1297
index: completed,
1298
total: plan.passes,
1299
label: format!("{pose} · highres"),
1300
});
1301
let scene = scene(
1302
&job,
1303
&key,
1304
frame as f32 / job.fps as f32,
1305
pitch as f32,
1306
yaw as f32,
1307
&mut cache,
1308
)?;
1309
let (tx, rx) = mpsc::sync_channel(1);
1310
gpu.send(GpuRequest { scene, reply: tx })
1311
.map_err(|_| "GPU-service gesloten")?;
1312
if let Some(ctx) = ctx {
1313
ctx.request_repaint();
1314
}
1315
let raw = loop {
1316
match rx.recv_timeout(std::time::Duration::from_millis(100)) {
1317
Ok(result) => break result?,
1318
Err(mpsc::RecvTimeoutError::Timeout) => {
1319
if cancel.load(Ordering::Relaxed) {
1320
return Err("geannuleerd".into());
1321
}
1322
}
1323
Err(_) => return Err("GPU-service gestopt".into()),
1324
}
1325
};
1326
completed += 1;
1327
emit(RenderEvent::Pass {
1328
index: completed - 1,
1329
total: plan.passes,
1330
label: format!("{pose} · highres"),
1331
image: Some(image(raw.width, raw.height, raw.diffuse.clone())),
1332
});
1333
if cancel.load(Ordering::Relaxed) {
1334
return Err("geannuleerd".into());
1335
}
1336
emit(RenderEvent::Step {
1337
index: completed,
1338
total: plan.passes,
1339
label: format!("{pose} · detail"),
1340
});
1341
let detail = detail_map(&raw)?;
1342
completed += 1;
1343
emit(RenderEvent::Pass {
1344
index: completed - 1,
1345
total: plan.passes,
1346
label: format!("{pose} · detail"),
1347
image: Some(detail_image(&raw, &detail)),
1348
});
1349
if cancel.load(Ordering::Relaxed) {
1350
return Err("geannuleerd".into());
1351
}
1352
emit(RenderEvent::Step {
1353
index: completed,
1354
total: plan.passes,
1355
label: format!("{pose} · target-res / diffuse"),
1356
});
1357
let target = resolve(&raw, &detail, job.target_width, job.target_height)?;
1358
for (map, preview) in [
1359
("diffuse", target.diffuse),
1360
("depth", target.depth_preview),
1361
("normal", target.normal),
1362
("specular", target.specular),
1363
] {
1364
if cancel.load(Ordering::Relaxed) {
1365
return Err("geannuleerd".into());
1366
}
1367
emit(RenderEvent::Step {
1368
index: completed,
1369
total: plan.passes,
1370
label: format!("{pose} · {map}"),
1371
});
1372
let path = output_path(&job.sex, &set, &clip.token, frame, pitch, yaw, map)?;
1373
if map == "depth" {
1374
write_depth(
1375
&root,
1376
&path,
1377
job.target_width,
1378
job.target_height,
1379
&target.depth,
1380
)?;
1381
} else {
1382
write_rgba(&root, &path, &preview)?;
1383
}
1384
completed += 1;
1385
emit(RenderEvent::Pass {
1386
index: completed - 1,
1387
total: plan.passes,
1388
label: format!(
1389
"{pose} · {}",
1390
if map == "diffuse" {
1391
"target-res / diffuse"
1392
} else {
1393
map
1394
}
1395
),
1396
image: Some(preview),
1397
});
1398
}
1399
}
1400
Ok(())
1401
})();
1402
match result {
1403
Ok(()) => {
1404
write_json(
1405
&root,
1406
"complete.json",
1407
serde_json::json!({"status":"complete","passes":completed,"png_files":plan.files}),
1408
)?;
1409
emit(RenderEvent::Finished {
1410
saved: Some(root.display().to_string()),
1411
});
1412
Ok(())
1413
}
1414
Err(error) => {
1415
let name = if cancel.load(Ordering::Relaxed) {
1416
"cancelled.json"
1417
} else {
1418
"failed.json"
1419
};
1420
let _ = write_json(
1421
&root,
1422
name,
1423
serde_json::json!({"error":error,"completed_passes":completed,"total_passes":plan.passes}),
1424
);
1425
Err(error)
1426
}
1427
}
1428
}
1429
1430
/// Real readback regression: nearest-surface depth/normal/specular, preserved
1431
/// texture × vertex albedo/alpha and independence from opposed light directions.
1432
/// # Safety
1433
/// Run on a current disposable native context.
1434
pub unsafe fn map_regression_probe(gl: &glow::Context) -> Result<(), String> {
1435
let triangle = |z, normal, color, specular| Draw {
1436
positions: Arc::new(vec![[-0.9, -0.9, z], [0.9, -0.9, z], [0., 0.9, z]]),
1437
normals: Arc::new(vec![normal; 3]),
1438
uvs: Arc::new(vec![[0.; 2]; 3]),
1439
vertex_colors: Arc::new(Vec::new()),
1440
indices: vec![0, 1, 2],
1441
color,
1442
albedo: None,
1443
shiny: None,
1444
specular,
1445
cutoff: 0.001,
1446
flat_normals: false,
1447
};
1448
let mut renderer = GpuRenderer::default();
1449
let mut scene = Scene {
1450
draws: vec![
1451
triangle(-0.5, [0., 0., 1.], [1., 0., 0., 1.], 0.75),
1452
triangle(0.5, [1., 0., 0.], [0., 1., 0., 1.], 0.),
1453
],
1454
view: preview::identity(),
1455
projection: preview::identity(),
1456
width: 32,
1457
height: 32,
1458
};
1459
let result = (|| {
1460
let raw = renderer.render(gl, &scene)?;
1461
let i = 16 * 32 + 16;
1462
if raw.diffuse[i * 4..i * 4 + 4] != [255, 0, 0, 255]
1463
|| (raw.depth[i] - 0.25).abs() > 1e-5
1464
|| raw.normal[i * 4..i * 4 + 3] != [128, 128, 255]
1465
|| raw.specular[i * 4].abs_diff(191) > 1
1466
{
1467
return Err(format!(
1468
"GPU map winner mismatch: color {:?} depth {} normal {:?} spec {}",
1469
&raw.diffuse[i * 4..i * 4 + 4],
1470
raw.depth[i],
1471
&raw.normal[i * 4..i * 4 + 4],
1472
raw.specular[i * 4]
1473
));
1474
}
1475
scene.draws.reverse();
1476
let reverse = renderer.render(gl, &scene)?;
1477
if reverse.diffuse != raw.diffuse
1478
|| reverse.normal != raw.normal
1479
|| reverse.depth != raw.depth
1480
{
1481
return Err("GPU map winner depends on submission order".into());
1482
}
1483
scene.draws = vec![triangle(-0.5, [0., 0., 1.], [0.5, 0.75, 1., 1.], 0.)];
1484
scene.draws[0].vertex_colors = Arc::new(vec![[0.5, 1., 0.5, 1.]; 3]);
1485
scene.draws[0].albedo = Some(Arc::new(equipment::Texture {
1486
content_hash: 99,
1487
width: 1,
1488
height: 1,
1489
rgba: vec![128, 255, 64, 192],
1490
}));
1491
let expected = [
1492
srgb(linear(128) * 0.25),
1493
srgb(0.75),
1494
srgb(linear(64) * 0.5),
1495
192,
1496
];
1497
// Production intentionally has no lighting input. This optional conventional
1498
// uniform is optimized out now; a future accidental lighting term using it
1499
// must fail these actual GPU readbacks under opposed light directions.
1500
for light in [[0., 0., 1.], [0., 0., -1.], [1., 0., 0.]] {
1501
let p = renderer.program.ok_or("missing probe program")?;
1502
gl.use_program(Some(p));
1503
gl.uniform_3_f32(
1504
gl.get_uniform_location(p, "u_light_direction").as_ref(),
1505
light[0],
1506
light[1],
1507
light[2],
1508
);
1509
let output = renderer.render(gl, &scene)?;
1510
let pixel = &output.diffuse[i * 4..i * 4 + 4];
1511
if pixel.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1) {
1512
return Err(format!("diffuse is lit or alpha/vertex/texture lost under {light:?}: {pixel:?} vs {expected:?}"));
1513
}
1514
}
1515
let detail = detail_map(&raw)?;
1516
let target = resolve(&raw, &detail, 4, 4)?;
1517
if target.diffuse.width != 4
1518
|| target.depth.len() != 32
1519
|| target
1520
.normal
1521
.rgba
1522
.chunks_exact(4)
1523
.filter(|p| p[3] > 0)
1524
.any(|p| {
1525
let n = p[..3]
1526
.iter()
1527
.map(|x| (*x as f32 / 255. * 2. - 1.).powi(2))
1528
.sum::<f32>()
1529
.sqrt();
1530
(n - 1.).abs() > 0.03
1531
})
1532
{
1533
return Err("target map dimensions/normals invalid".into());
1534
}
1535
let mut flat = Scene {
1536
draws: vec![triangle(-0.5, [1., 0., 0.], [1., 0., 0., 1.], 0.)],
1537
view: preview::identity(),
1538
projection: preview::identity(),
1539
width: 32,
1540
height: 32,
1541
};
1542
flat.draws[0].flat_normals = true;
1543
let derived = renderer.render(gl, &flat)?;
1544
if derived.normal[i * 4..i * 4 + 3] != [128, 128, 255] {
1545
return Err(format!(
1546
"flat normals are not geometry-derived: {:?}",
1547
&derived.normal[i * 4..i * 4 + 4]
1548
));
1549
}
1550
println!("Production GPU maps PASS: nearest depth=0.25, normal=[128,128,255], specular=191, albedo texture×vertex={expected:?}, alpha preserved, diffuse identical under 3 opposed lights; target maps valid; missing source normals use actual GPU triangle derivatives");
1551
Ok(())
1552
})();
1553
renderer.destroy(gl);
1554
result
1555
}
1556
1557
#[cfg(test)]
1558
mod tests {
1559
use super::*;
1560
struct Temp(PathBuf);
1561
impl Temp {
1562
fn new() -> Self {
1563
let p = std::env::temp_dir().join(format!(
1564
"paperdoll-production-{}-{}",
1565
std::process::id(),
1566
std::time::SystemTime::now()
1567
.duration_since(std::time::UNIX_EPOCH)
1568
.unwrap()
1569
.as_nanos()
1570
));
1571
std::fs::create_dir(&p).unwrap();
1572
Self(p)
1573
}
1574
}
1575
impl Drop for Temp {
1576
fn drop(&mut self) {
1577
let _ = std::fs::remove_dir_all(&self.0);
1578
}
1579
}
1580
fn body() -> PreviewCharacter {
1581
preview::load_gltf_file(
1582
&Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
1583
)
1584
.unwrap()
1585
}
1586
fn raw(w: u32, h: u32) -> Buffers {
1587
let n = (w * h) as usize;
1588
Buffers {
1589
width: w,
1590
height: h,
1591
diffuse: [255, 0, 0, 255].repeat(n),
1592
normal: [128, 128, 255, 255].repeat(n),
1593
specular: [191, 191, 191, 255].repeat(n),
1594
depth: vec![0.25; n],
1595
}
1596
}
1597
#[test]
1598
fn job_counts_every_clip_frame_angle_and_exact_order() {
1599
let mut b = body();
1600
let mut c = b.clips[0].clone();
1601
c.name = "../A \u{2603}".into();
1602
c.duration = 0.5;
1603
b.clips = vec![c.clone(), c];
1604
let p = plan(&b, 3).unwrap();
1605
assert_eq!((p.images, p.passes, p.files), (144, 864, 576));
1606
let s = samples(&p).collect::<Vec<_>>();
1607
assert_eq!(s.len(), 144);
1608
assert_eq!(
1609
s[0],
1610
Sample {
1611
clip: 0,
1612
frame: 0,
1613
pitch: -30,
1614
yaw: 0
1615
}
1616
);
1617
assert_eq!(s[11].yaw, 330);
1618
assert_eq!(s[12].pitch, 0);
1619
assert_eq!(s[35].pitch, 30);
1620
assert_eq!(s[36].frame, 1);
1621
assert_eq!(s[72].clip, 1);
1622
assert_ne!(p.clips[0].token, p.clips[1].token);
1623
assert!(p.clips.iter().all(|c| equipment::valid_name(&c.token)));
1624
assert_eq!(
1625
PASSES,
1626
[
1627
"highres",
1628
"detail",
1629
"target-res / diffuse",
1630
"depth",
1631
"normal",
1632
"specular"
1633
]
1634
);
1635
b.clips.clear();
1636
let p = plan(&b, 12).unwrap();
1637
assert_eq!((p.images, p.files), (36, 144));
1638
}
1639
#[test]
1640
fn floating_terminal_timestamp_does_not_duplicate_the_loop_endpoint() {
1641
let mut b = body();
1642
b.clips.truncate(1);
1643
b.clips[0].duration = f32::from_bits(1f32.to_bits() + 1);
1644
assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 2);
1645
b.clips[0].duration = 1.00001;
1646
assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 3);
1647
}
1648
#[test]
1649
fn planner_rejects_invalid_or_unbounded_inputs() {
1650
let mut b = body();
1651
assert!(plan(&b, 0).is_err());
1652
assert!(plan(&b, 241).is_err());
1653
for duration in [f32::NAN, f32::INFINITY, -1., 10000.] {
1654
b.clips[0].duration = duration;
1655
assert!(plan(&b, 12).is_err());
1656
}
1657
}
1658
#[test]
1659
fn output_tokens_only_four_target_maps() {
1660
let expected = PathBuf::from("male/body/clip000-walk/f000012/p-30_y330_diffuse.png");
1661
assert_eq!(
1662
output_path("male", "body", "clip000-walk", 12, -30, 330, "diffuse").unwrap(),
1663
expected
1664
);
1665
for map in ["diffuse", "normal", "depth", "specular"] {
1666
assert!(output_path("female", "sword", "clip000-bind", 0, 30, 0, map).is_ok());
1667
}
1668
for map in ["highres", "detail", "shiny", "../x"] {
1669
assert!(output_path("male", "body", "clip000-bind", 0, 0, 0, map).is_err());
1670
}
1671
for token in ["../escape", "A", "a/b", "a\\b", ".", "a b"] {
1672
assert!(output_path("male", token, "clip000-bind", 0, 0, 0, "diffuse").is_err());
1673
}
1674
assert!(output_path("male", "body", "walk", 0, 1, 0, "depth").is_err());
1675
assert!(output_path("male", "body", "walk", 0, 0, 360, "depth").is_err());
1676
}
1677
#[test]
1678
fn resolve_preserves_thin_foreground_and_matching_maps() {
1679
let mut r = raw(8, 8);
1680
r.diffuse = [0, 255, 0, 255].repeat(64);
1681
r.depth.fill(0.75);
1682
r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]);
1683
r.depth[0] = 0.25;
1684
let d = detail_map(&r).unwrap();
1685
assert!(d.iter().any(|x| *x > 0));
1686
let t = resolve(&r, &d, 1, 1).unwrap();
1687
assert_eq!(t.diffuse.rgba, [255, 0, 0, 255]);
1688
assert_eq!(t.specular.rgba, [191, 191, 191, 255]);
1689
assert!((t.depth[0] as i32 - 16384).abs() <= 1);
1690
let n = &t.normal.rgba;
1691
let len = (0..3)
1692
.map(|c| (n[c] as f32 / 255. * 2. - 1.).powi(2))
1693
.sum::<f32>()
1694
.sqrt();
1695
assert!((len - 1.).abs() < 0.01);
1696
r.diffuse.fill(0);
1697
r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]);
1698
let t = resolve(&r, &detail_map(&r).unwrap(), 1, 1).unwrap();
1699
assert_eq!(t.diffuse.rgba[3], 4);
1700
assert_eq!(t.depth[1], 1028);
1701
assert_eq!(t.normal.rgba[3], 4);
1702
assert_eq!(t.specular.rgba[3], 4);
1703
}
1704
#[test]
1705
fn invalid_map_dimensions_and_depth_fail_without_panics() {
1706
let mut r = raw(8, 8);
1707
r.normal.pop();
1708
assert!(detail_map(&r).is_err());
1709
assert!(resolve(&r, &[0; 64], 1, 1).is_err());
1710
let mut r = raw(8, 8);
1711
r.depth[0] = f32::NAN;
1712
assert!(resolve(&r, &[0; 64], 1, 1).is_err());
1713
let r = raw(8, 8);
1714
assert!(resolve(&r, &[0; 63], 1, 1).is_err());
1715
assert!(resolve(&r, &[0; 64], 0, 1).is_err());
1716
assert!(resolve(&r, &[0; 64], 257, 1).is_err());
1717
}
1718
#[test]
1719
fn atomic_pngs_roundtrip_no_overwrite_or_temp_deletion() {
1720
let t = Temp::new();
1721
let p = Path::new("map_diffuse.png");
1722
write_rgba(&t.0, p, &image(1, 1, vec![128, 64, 32, 192])).unwrap();
1723
let original = std::fs::read(t.0.join(p)).unwrap();
1724
assert!(write_rgba(&t.0, p, &image(1, 1, vec![0; 4])).is_err());
1725
assert_eq!(std::fs::read(t.0.join(p)).unwrap(), original);
1726
let mut reader = png::Decoder::new(std::io::Cursor::new(original))
1727
.read_info()
1728
.unwrap();
1729
assert!(reader.info().srgb.is_some());
1730
let mut out = vec![0; reader.output_buffer_size().unwrap()];
1731
reader.next_frame(&mut out).unwrap();
1732
assert_eq!(out, [128, 64, 32, 192]);
1733
write_depth(&t.0, Path::new("depth.png"), 1, 1, &[16384, 32896]).unwrap();
1734
let mut reader = png::Decoder::new(std::io::BufReader::new(
1735
std::fs::File::open(t.0.join("depth.png")).unwrap(),
1736
))
1737
.read_info()
1738
.unwrap();
1739
assert_eq!(reader.info().bit_depth, png::BitDepth::Sixteen);
1740
assert_eq!(reader.info().color_type, png::ColorType::GrayscaleAlpha);
1741
assert!(reader.info().srgb.is_none());
1742
let mut out = vec![0; reader.output_buffer_size().unwrap()];
1743
reader.next_frame(&mut out).unwrap();
1744
assert_eq!(out, [64, 0, 128, 128]);
1745
let tmp =
1746
t.0.join("blocked.png")
1747
.with_extension(format!("part-{}", std::process::id()));
1748
std::fs::write(&tmp, b"keep").unwrap();
1749
assert!(write_rgba(&t.0, Path::new("blocked.png"), &image(1, 1, vec![0; 4])).is_err());
1750
assert_eq!(std::fs::read(tmp).unwrap(), b"keep");
1751
assert!(write_depth(&t.0, Path::new("bad.png"), 1, 1, &[0]).is_err());
1752
assert!(write_rgba(&t.0, Path::new("bad.png"), &image(257, 1, vec![0; 1028])).is_err());
1753
assert!(write_rgba(&t.0, Path::new("../escape.png"), &image(1, 1, vec![0; 4])).is_err());
1754
}
1755
#[cfg(unix)]
1756
#[test]
1757
fn output_rejects_escaping_symlinks() {
1758
let root = Temp::new();
1759
let outside = Temp::new();
1760
std::os::unix::fs::symlink(&outside.0, root.0.join("link")).unwrap();
1761
assert!(write_rgba(
1762
&root.0,
1763
Path::new("link/sub/x.png"),
1764
&image(1, 1, vec![0; 4])
1765
)
1766
.is_err());
1767
assert!(!outside.0.join("sub").exists());
1768
}
1769
#[test]
1770
fn latest_mailbox_is_bounded_and_retains_terminal() {
1771
let m = Mailbox::default();
1772
for i in 0..1000 {
1773
m.publish(RenderEvent::Pass {
1774
index: i,
1775
total: 1000,
1776
label: i.to_string(),
1777
image: Some(image(1, 1, vec![0; 4])),
1778
});
1779
}
1780
m.publish(RenderEvent::Step {
1781
index: 999,
1782
total: 1000,
1783
label: "working".into(),
1784
});
1785
m.publish(RenderEvent::Cancelled);
1786
let events = m.drain();
1787
assert_eq!(events.len(), 3);
1788
assert!(matches!(&events[0], RenderEvent::Pass { index: 999, .. }));
1789
assert!(matches!(&events[2], RenderEvent::Cancelled));
1790
assert!(m.drain().is_empty());
1791
}
1792
1793
#[test]
1794
fn fixed_body_and_equipment_camera_agree_and_body_is_not_a_draw() {
1795
let temp = Temp::new();
1796
let mut job = RenderJob {
1797
character: body(),
1798
sex: "male".into(),
1799
preset: None,
1800
target_width: 64,
1801
target_height: 96,
1802
fps: 12,
1803
framing: CameraFraming {
1804
height_milli: 3100,
1805
center_y_milli: 1200,
1806
},
1807
output: temp.0.clone(),
1808
};
1809
let mut cache = equipment::TextureCache::default();
1810
let b = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap();
1811
let mut preset = equipment::Preset::new("sword".into());
1812
let fixture =
1813
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword.obj");
1814
equipment::import_obj(&mut preset, &fixture).unwrap();
1815
let joint = job
1816
.character
1817
.joint_names
1818
.iter()
1819
.position(|n| n == "RightHand")
1820
.unwrap();
1821
preset.bind(0, &job.character, 0, joint).unwrap();
1822
let count = preset
1823
.attachments
1824
.iter()
1825
.map(|a| a.meshes.len())
1826
.sum::<usize>();
1827
job.preset = Some(preset);
1828
let e = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap();
1829
assert_eq!(e.draws.len(), count);
1830
assert_eq!(b.view, e.view);
1831
assert_eq!(b.projection, e.projection);
1832
assert_eq!((e.width, e.height), (512, 768));
1833
assert_eq!(e.projection[5], 2. / 3.1);
1834
let center = point(&e.view, [0., 1.2, 0.]);
1835
assert!(center[0].abs() < 0.0001 && center[1].abs() < 0.0001);
1836
assert!((center[2] + 10.).abs() < 0.0001);
1837
let early = scene(&job, "attack", 0., 30., 90., &mut cache).unwrap();
1838
let late = scene(&job, "attack", 0.5, 30., 90., &mut cache).unwrap();
1839
assert_eq!(early.view, late.view);
1840
assert_eq!(early.projection, late.projection);
1841
assert_ne!(early.draws[0].positions, late.draws[0].positions);
1842
let mut invalid = e.clone();
1843
invalid.view[0] = f32::NAN;
1844
assert!(invalid.validate().is_err());
1845
}
1846
#[test]
1847
fn body_import_retains_texture_material_and_linear_vertex_color() {
1848
let temp = Temp::new();
1849
let source = std::fs::read(
1850
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
1851
)
1852
.unwrap();
1853
let glb = gltf::binary::Glb::from_slice(&source).unwrap();
1854
let mut json: serde_json::Value = serde_json::from_slice(&glb.json).unwrap();
1855
let mut bin = glb.bin.unwrap().into_owned();
1856
while !bin.len().is_multiple_of(4) {
1857
bin.push(0);
1858
}
1859
let position = json["meshes"][0]["primitives"][0]["attributes"]["POSITION"]
1860
.as_u64()
1861
.unwrap() as usize;
1862
let count = json["accessors"][position]["count"].as_u64().unwrap() as usize;
1863
let offset = bin.len();
1864
for _ in 0..count {
1865
for value in [0.25f32, 0.75, 0.5, 0.5] {
1866
bin.extend(value.to_le_bytes());
1867
}
1868
}
1869
let view = json["bufferViews"].as_array().unwrap().len();
1870
json["bufferViews"]
1871
.as_array_mut()
1872
.unwrap()
1873
.push(serde_json::json!({"buffer":0,"byteOffset":offset,"byteLength":count*16}));
1874
let accessor = json["accessors"].as_array().unwrap().len();
1875
json["accessors"].as_array_mut().unwrap().push(
1876
serde_json::json!({"bufferView":view,"componentType":5126,"count":count,"type":"VEC4"}),
1877
);
1878
json["meshes"][0]["primitives"][0]["attributes"]["COLOR_0"] = serde_json::json!(accessor);
1879
json["buffers"][0] = serde_json::json!({"uri":"geometry.bin","byteLength":bin.len()});
1880
json["images"] = serde_json::json!([{"uri":"albedo.png"}]);
1881
json["textures"] = serde_json::json!([{"source":0}]);
1882
let material = json["meshes"][0]["primitives"][0]["material"]
1883
.as_u64()
1884
.unwrap() as usize;
1885
json["materials"][material]["pbrMetallicRoughness"]["baseColorTexture"] =
1886
serde_json::json!({"index":0});
1887
json["materials"][material]["pbrMetallicRoughness"]["baseColorFactor"] =
1888
serde_json::json!([0.5, 0.75, 1., 1.]);
1889
std::fs::write(temp.0.join("geometry.bin"), bin).unwrap();
1890
std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap();
1891
write_rgba(
1892
&temp.0,
1893
Path::new("albedo.png"),
1894
&image(1, 1, vec![128, 255, 64, 192]),
1895
)
1896
.unwrap();
1897
let body = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap();
1898
assert_eq!(body.colors[0], [0.25, 0.75, 0.5, 0.5]);
1899
assert_eq!(body.surfaces[0].color, [0.5, 0.75, 1., 1.]);
1900
assert_eq!(
1901
body.surfaces[0].albedo.as_ref().unwrap().rgba,
1902
[128, 255, 64, 192]
1903
);
1904
assert_eq!(body.colors.len(), body.positions.len());
1905
assert_eq!(body.uvs.len(), body.positions.len());
1906
assert!(body.surfaces[0].flat_normals);
1907
json["meshes"][0]["primitives"][0]["attributes"]
1908
.as_object_mut()
1909
.unwrap()
1910
.remove("NORMAL");
1911
std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap();
1912
let missing = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap();
1913
assert!(!missing.source_normals_complete);
1914
let job = RenderJob {
1915
character: missing,
1916
sex: "male".into(),
1917
preset: None,
1918
target_width: 1,
1919
target_height: 1,
1920
fps: 12,
1921
framing: CameraFraming::default(),
1922
output: temp.0.clone(),
1923
};
1924
assert!(
1925
scene(
1926
&job,
1927
"",
1928
0.,
1929
0.,
1930
0.,
1931
&mut equipment::TextureCache::default()
1932
)
1933
.unwrap()
1934
.draws[0]
1935
.flat_normals
1936
);
1937
}
1938
#[test]
1939
fn complete_and_cancelled_batches_have_truthful_atomic_outputs() {
1940
for stop in [false, true] {
1941
let tmp = Temp::new();
1942
let mut b = body();
1943
b.clips.clear();
1944
let job = RenderJob {
1945
character: b,
1946
sex: "male".into(),
1947
preset: None,
1948
target_width: 1,
1949
target_height: 1,
1950
fps: 12,
1951
framing: CameraFraming::default(),
1952
output: tmp.0.clone(),
1953
};
1954
let flag = AtomicBool::new(false);
1955
let (tx, rx) = mpsc::channel::<GpuRequest>();
1956
let gpu = std::thread::spawn(move || {
1957
for request in rx {
1958
let _ = request
1959
.reply
1960
.send(Ok(raw(request.scene.width, request.scene.height)));
1961
}
1962
});
1963
let mut labels = Vec::new();
1964
let result = execute(job, &tx, &flag, None, &mut |event| {
1965
if let RenderEvent::Pass { label, .. } = event {
1966
labels.push(label);
1967
if stop && labels.len() == 3 {
1968
flag.store(true, Ordering::Relaxed);
1969
}
1970
}
1971
});
1972
drop(tx);
1973
gpu.join().unwrap();
1974
let run = std::fs::read_dir(&tmp.0)
1975
.unwrap()
1976
.next()
1977
.unwrap()
1978
.unwrap()
1979
.path();
1980
assert!(run.join("render.json").exists());
1981
assert_eq!(run.join("complete.json").exists(), !stop);
1982
assert_eq!(run.join("cancelled.json").exists(), stop);
1983
assert_eq!(result.is_ok(), !stop);
1984
let frame = run.join("male/body/clip000-bind/f000000");
1985
assert!(frame.join("p-30_y000_diffuse.png").exists());
1986
assert_eq!(frame.join("p-30_y000_depth.png").exists(), !stop);
1987
assert!(!frame.join("p-30_y000_highres.png").exists());
1988
assert!(!frame.join("p-30_y000_detail.png").exists());
1989
if !stop {
1990
assert_eq!(labels.len(), 216);
1991
for labels in labels.chunks_exact(6) {
1992
for (label, pass) in labels.iter().zip([
1993
"highres",
1994
"detail",
1995
"target-res / diffuse",
1996
"depth",
1997
"normal",
1998
"specular",
1999
]) {
2000
assert!(label.ends_with(pass));
2001
}
2002
}
2003
}
2004
}
2005
}
2006
}
@@ -0,0 +1,2006 @@1
//! Bounded orthographic production rendering. GPU work runs on the native2
//! current context, one request per UI update; sampling/filtering/PNG I/O run3
//! on the worker. The worker never waits for a preview acknowledgment.4
use crate::{5
equipment,6
preview::{self, Mat4, PreviewCharacter, PreviewControls},7
project::CameraFraming,8
render_job::{ImageBuf, RenderEvent},9
};10
use glow::HasContext;11
use std::{12
path::{Path, PathBuf},13
sync::{14
atomic::{AtomicBool, Ordering},15
mpsc, Arc, Mutex,16
},17
thread,18
};19
20
pub const SCALE: u32 = 8;21
pub const MAX_HIGHRES: u32 = 2048;22
pub const PASSES: [&str; 6] = [23
"highres",24
"detail",25
"target-res / diffuse",26
"depth",27
"normal",28
"specular",29
];30
pub const NEAR: f32 = 0.05;31
pub const FAR: f32 = 40.;32
#[derive(Clone)]33
pub struct Draw {34
pub positions: Arc<Vec<[f32; 3]>>,35
pub normals: Arc<Vec<[f32; 3]>>,36
pub uvs: Arc<Vec<[f32; 2]>>,37
pub vertex_colors: Arc<Vec<[f32; 4]>>,38
pub indices: Vec<u32>,39
pub color: [f32; 4],40
pub albedo: Option<Arc<equipment::Texture>>,41
pub shiny: Option<Arc<equipment::Texture>>,42
pub specular: f32,43
pub cutoff: f32,44
pub flat_normals: bool,45
}46
#[derive(Clone)]47
pub struct Scene {48
pub draws: Vec<Draw>,49
pub view: Mat4,50
pub projection: Mat4,51
pub width: u32,52
pub height: u32,53
}54
#[derive(Debug)]55
pub struct Buffers {56
pub width: u32,57
pub height: u32,58
pub diffuse: Vec<u8>,59
pub normal: Vec<u8>,60
pub specular: Vec<u8>,61
pub depth: Vec<f32>,62
}63
#[derive(Debug)]64
pub struct Target {65
pub diffuse: ImageBuf,66
pub normal: ImageBuf,67
pub specular: ImageBuf,68
pub depth: Vec<u16>,69
pub depth_preview: ImageBuf,70
}71
#[derive(Clone)]72
pub struct RenderJob {73
pub character: PreviewCharacter,74
pub sex: String,75
pub preset: Option<equipment::Preset>,76
pub target_width: u32,77
pub target_height: u32,78
pub fps: u32,79
pub framing: CameraFraming,80
pub output: PathBuf,81
}82
#[derive(Debug, Clone, PartialEq)]83
pub struct ClipPlan {84
pub source_name: String,85
pub token: String,86
pub frames: u32,87
}88
#[derive(Debug, Clone, PartialEq)]89
pub struct Plan {90
pub clips: Vec<ClipPlan>,91
pub images: u32,92
pub passes: u32,93
pub files: u32,94
}95
pub fn plan(body: &PreviewCharacter, fps: u32) -> Result<Plan, String> {96
if fps == 0 || fps > 240 {97
return Err("fps moet 1–240 zijn".into());98
}99
let clips = if body.clips.is_empty() {100
vec![ClipPlan {101
source_name: String::new(),102
token: "clip000-bind".into(),103
frames: 1,104
}]105
} else {106
body.clips107
.iter()108
.enumerate()109
.map(|(i, c)| {110
let duration = c.duration;111
if !duration.is_finite() || duration < 0. || duration * fps as f32 > 100000. {112
return Err("ongeldige of te lange animatie".into());113
}114
// Exporter float timestamps commonly put a nominal 1-second115
// endpoint one ULP above 1. Do not duplicate that terminal pose.116
let samples = f64::from(duration) * f64::from(fps);117
let nearest = samples.round();118
let samples = if (samples - nearest).abs()119
<= 4. * f64::from(f32::EPSILON) * samples.max(1.)120
{121
nearest122
} else {123
samples124
};125
Ok(ClipPlan {126
source_name: c.name.clone(),127
token: clip_token(i, &c.name),128
frames: samples.ceil().max(1.) as u32,129
})130
})131
.collect::<Result<Vec<_>, String>>()?132
};133
let frames = clips134
.iter()135
.try_fold(0u32, |n, c| n.checked_add(c.frames))136
.ok_or("te veel frames")?;137
let images = frames.checked_mul(36).ok_or("te veel beelden")?;138
let passes = images.checked_mul(6).ok_or("te veel passes")?;139
let files = images.checked_mul(4).ok_or("te veel bestanden")?;140
if files > 500000 {141
return Err("batch groter dan 500000 uitvoerbestanden".into());142
}143
Ok(Plan {144
clips,145
images,146
passes,147
files,148
})149
}150
#[derive(Debug, Clone, Copy, PartialEq, Eq)]151
pub struct Sample {152
pub clip: usize,153
pub frame: u32,154
pub pitch: i32,155
pub yaw: u32,156
}157
pub fn samples(plan: &Plan) -> impl Iterator<Item = Sample> + '_ {158
plan.clips.iter().enumerate().flat_map(|(clip, c)| {159
(0..c.frames).flat_map(move |frame| {160
[-30, 0, 30].into_iter().flat_map(move |pitch| {161
(0..12).map(move |y| Sample {162
clip,163
frame,164
pitch,165
yaw: y * 30,166
})167
})168
})169
})170
}171
172
pub fn clip_token(index: usize, name: &str) -> String {173
let slug: String = name174
.chars()175
.map(|c| {176
if c.is_ascii_alphanumeric() {177
c.to_ascii_lowercase()178
} else {179
'-'180
}181
})182
.take(48)183
.collect();184
format!(185
"clip{index:03}-{}",186
if slug.is_empty() { "animation" } else { &slug }187
)188
}189
pub fn output_path(190
sex: &str,191
set: &str,192
clip: &str,193
frame: u32,194
pitch: i32,195
yaw: u32,196
map: &str,197
) -> Result<PathBuf, String> {198
if !["male", "female"].contains(&sex)199
|| !(set == "body" || equipment::valid_name(set))200
|| !equipment::valid_name(clip)201
|| ![-30, 0, 30].contains(&pitch)202
|| yaw >= 360203
|| !yaw.is_multiple_of(30)204
|| !["diffuse", "normal", "depth", "specular"].contains(&map)205
{206
return Err("ongeldig uitvoerpad/token".into());207
}208
Ok(PathBuf::from(sex)209
.join(set)210
.join(clip)211
.join(format!("f{frame:06}"))212
.join(format!("p{pitch:+03}_y{yaw:03}_{map}.png")))213
}214
pub fn camera(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (Mat4, Mat4) {215
let target = [0., framing.center_y_milli as f32 / 1000., 0.];216
let d = preview::eye_offset(yaw, pitch, 10.);217
let eye = [d[0], target[1] + d[1], d[2]];218
let half = framing.height_milli as f32 / 2000.;219
let mut p = preview::identity();220
p[0] = 1. / (half * aspect);221
p[5] = 1. / half;222
p[10] = -2. / (FAR - NEAR);223
p[14] = -(FAR + NEAR) / (FAR - NEAR);224
(preview::view_matrix(eye, target), p)225
}226
fn point(m: &Mat4, p: [f32; 3]) -> [f32; 3] {227
std::array::from_fn(|i| m[i] * p[0] + m[i + 4] * p[1] + m[i + 8] * p[2] + m[i + 12])228
}229
fn normalize(v: [f32; 3]) -> [f32; 3] {230
let l = v.iter().map(|x| x * x).sum::<f32>().sqrt();231
if l > 1e-10 {232
v.map(|x| x / l)233
} else {234
[0., 0., 1.]235
}236
}237
fn normal(m: &Mat4, n: [f32; 3]) -> [f32; 3] {238
let a = [m[0], m[1], m[2]];239
let b = [m[4], m[5], m[6]];240
let c = [m[8], m[9], m[10]];241
let cross = |a: [f32; 3], b: [f32; 3]| {242
[243
a[1] * b[2] - a[2] * b[1],244
a[2] * b[0] - a[0] * b[2],245
a[0] * b[1] - a[1] * b[0],246
]247
};248
let x = cross(b, c);249
let y = cross(c, a);250
let z = cross(a, b);251
let det = a.iter().zip(x).map(|(a, b)| a * b).sum::<f32>();252
if det.abs() < 1e-12 {253
return normalize(n);254
}255
normalize(std::array::from_fn(|i| {256
(x[i] * n[0] + y[i] * n[1] + z[i] * n[2]) / det257
}))258
}259
pub fn scene(260
job: &RenderJob,261
clip: &str,262
time: f32,263
pitch: f32,264
yaw: f32,265
cache: &mut equipment::TextureCache,266
) -> Result<Scene, String> {267
let body = &job.character;268
let mut draws = Vec::new();269
if let Some(preset) = &job.preset {270
let controls = PreviewControls {271
clip_name: clip.into(),272
time,273
..Default::default()274
};275
for layer in equipment::layers_cached(276
preset,277
usize::from(job.sex == "female"),278
body,279
&controls,280
cache,281
)? {282
let m = layer.model;283
draws.push(Draw {284
positions: Arc::new(layer.mesh.positions.iter().map(|p| point(&m, *p)).collect()),285
normals: Arc::new(layer.mesh.normals.iter().map(|n| normal(&m, *n)).collect()),286
uvs: Arc::new(287
layer288
.mesh289
.uvs290
.into_iter()291
.map(|[u, v]| [u, 1. - v])292
.collect(),293
),294
vertex_colors: Arc::new(Vec::new()),295
indices: layer.mesh.indices,296
color: layer.mesh.color,297
albedo: layer.albedo,298
shiny: layer.shiny,299
specular: layer.specular,300
cutoff: 0.5,301
flat_normals: false,302
});303
}304
} else {305
let bones = preview::sample_skin_matrices(body, clip, time);306
let mut positions = Vec::with_capacity(body.positions.len());307
let mut normals = Vec::with_capacity(body.positions.len());308
for (i, p) in body.positions.iter().enumerate() {309
let mut m = preview::identity();310
if !bones.is_empty() && body.weights.len() == body.positions.len() {311
m = [0.; 16];312
let joints = body.joints.get(i).copied().unwrap_or([0; 4]);313
for (j, w) in joints.into_iter().zip(body.weights[i]) {314
let b = bones.get(j as usize).ok_or("ongeldige skinjoint")?;315
for k in 0..16 {316
m[k] += b[k] * w;317
}318
}319
}320
positions.push(point(&m, *p));321
normals.push(normal(322
&m,323
body.normals.get(i).copied().unwrap_or([0., 1., 0.]),324
));325
}326
let positions = Arc::new(positions);327
let normals = Arc::new(normals);328
let uvs = Arc::new(body.uvs.clone());329
let colors = Arc::new(body.colors.clone());330
if body.surfaces.is_empty() {331
draws.push(Draw {332
positions,333
normals,334
uvs,335
vertex_colors: colors,336
indices: body.indices.clone(),337
color: body.base_color,338
albedo: None,339
shiny: None,340
specular: 0.,341
cutoff: 0.001,342
flat_normals: !body.source_normals_complete,343
});344
} else {345
for surface in &body.surfaces {346
draws.push(Draw {347
positions: positions.clone(),348
normals: normals.clone(),349
uvs: uvs.clone(),350
vertex_colors: colors.clone(),351
indices: body.indices[surface.first..surface.first + surface.count].to_vec(),352
color: surface.color,353
albedo: surface.albedo.clone(),354
shiny: None,355
specular: 0.,356
cutoff: surface.cutoff,357
flat_normals: surface.flat_normals,358
});359
}360
}361
}362
let (view, projection) = camera(363
job.framing,364
yaw,365
pitch,366
job.target_width as f32 / job.target_height as f32,367
);368
Ok(Scene {369
draws,370
view,371
projection,372
width: job.target_width * SCALE,373
height: job.target_height * SCALE,374
})375
}376
377
impl Scene {378
pub fn validate(&self) -> Result<(), String> {379
if self.width == 0380
|| self.height == 0381
|| self.width > MAX_HIGHRES382
|| self.height > MAX_HIGHRES383
|| self.draws.len() > 4096384
|| self385
.view386
.iter()387
.chain(&self.projection)388
.any(|v| !v.is_finite())389
{390
return Err("ongeldige GPU-scène/afmetingen".into());391
}392
for d in &self.draws {393
if d.positions.len() > 1_000_000394
|| d.positions.len() != d.normals.len()395
|| d.indices.len() > 3_000_000396
|| !d.indices.len().is_multiple_of(3)397
|| d.indices.iter().any(|i| *i as usize >= d.positions.len())398
|| d.positions399
.iter()400
.chain(d.normals.iter())401
.flatten()402
.chain(d.uvs.iter().flatten())403
.chain(d.vertex_colors.iter().flatten())404
.chain(d.color.iter())405
.any(|v| !v.is_finite())406
|| !d.specular.is_finite()407
|| !d.cutoff.is_finite()408
{409
return Err("ongeldige of te grote GPU-geometrie".into());410
}411
for t in [d.albedo.as_ref(), d.shiny.as_ref()].into_iter().flatten() {412
if t.width == 0413
|| t.height == 0414
|| t.width > 2048415
|| t.height > 2048416
|| t.rgba.len() != t.width as usize * t.height as usize * 4417
{418
return Err("ongeldige GPU-texture".into());419
}420
}421
}422
Ok(())423
}424
}425
426
#[derive(Default)]427
pub struct GpuRenderer {428
program: Option<glow::Program>,429
vao: Option<glow::VertexArray>,430
vbo: Option<glow::Buffer>,431
ebo: Option<glow::Buffer>,432
fbo: Option<glow::Framebuffer>,433
colors: Vec<glow::Texture>,434
depth: Option<glow::Renderbuffer>,435
extent: [u32; 2],436
}437
impl GpuRenderer {438
/// # Safety439
/// A native OpenGL 3.3 context must be current on this thread.440
pub unsafe fn destroy(&mut self, gl: &glow::Context) {441
self.destroy_target(gl);442
if let Some(x) = self.program.take() {443
gl.delete_program(x);444
}445
if let Some(x) = self.vao.take() {446
gl.delete_vertex_array(x);447
}448
if let Some(x) = self.vbo.take() {449
gl.delete_buffer(x);450
}451
if let Some(x) = self.ebo.take() {452
gl.delete_buffer(x);453
}454
}455
unsafe fn destroy_target(&mut self, gl: &glow::Context) {456
for x in self.colors.drain(..) {457
gl.delete_texture(x);458
}459
if let Some(x) = self.depth.take() {460
gl.delete_renderbuffer(x);461
}462
if let Some(x) = self.fbo.take() {463
gl.delete_framebuffer(x);464
}465
self.extent = [0, 0];466
}467
unsafe fn init(&mut self, gl: &glow::Context) -> Result<(), String> {468
if self.program.is_some() {469
return Ok(());470
}471
let p = gl.create_program()?;472
let mut shaders = Vec::new();473
for (kind, source) in [474
(glow::VERTEX_SHADER, VERTEX),475
(glow::FRAGMENT_SHADER, FRAGMENT),476
] {477
let shader = gl.create_shader(kind)?;478
gl.shader_source(shader, source);479
gl.compile_shader(shader);480
if !gl.get_shader_compile_status(shader) {481
let error = gl.get_shader_info_log(shader);482
gl.delete_shader(shader);483
for s in shaders {484
gl.delete_shader(s);485
}486
gl.delete_program(p);487
return Err(error);488
}489
gl.attach_shader(p, shader);490
shaders.push(shader);491
}492
gl.link_program(p);493
let result = if gl.get_program_link_status(p) {494
Ok(())495
} else {496
Err(gl.get_program_info_log(p))497
};498
for s in shaders {499
gl.detach_shader(p, s);500
gl.delete_shader(s);501
}502
if let Err(e) = result {503
gl.delete_program(p);504
return Err(e);505
}506
self.program = Some(p);507
self.vao = Some(gl.create_vertex_array()?);508
self.vbo = Some(gl.create_buffer()?);509
self.ebo = Some(gl.create_buffer()?);510
Ok(())511
}512
unsafe fn target(&mut self, gl: &glow::Context, w: u32, h: u32) -> Result<(), String> {513
if self.extent == [w, h] {514
return Ok(());515
}516
self.destroy_target(gl);517
self.fbo = Some(gl.create_framebuffer()?);518
gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);519
for slot in 0..3 {520
let t = gl.create_texture()?;521
self.colors.push(t);522
gl.bind_texture(glow::TEXTURE_2D, Some(t));523
gl.tex_parameter_i32(524
glow::TEXTURE_2D,525
glow::TEXTURE_MIN_FILTER,526
glow::NEAREST as i32,527
);528
gl.tex_parameter_i32(529
glow::TEXTURE_2D,530
glow::TEXTURE_MAG_FILTER,531
glow::NEAREST as i32,532
);533
gl.tex_image_2d(534
glow::TEXTURE_2D,535
0,536
glow::RGBA8 as i32,537
w as i32,538
h as i32,539
0,540
glow::RGBA,541
glow::UNSIGNED_BYTE,542
None,543
);544
gl.framebuffer_texture_2d(545
glow::FRAMEBUFFER,546
glow::COLOR_ATTACHMENT0 + slot,547
glow::TEXTURE_2D,548
Some(t),549
0,550
);551
}552
let d = gl.create_renderbuffer()?;553
self.depth = Some(d);554
gl.bind_renderbuffer(glow::RENDERBUFFER, Some(d));555
gl.renderbuffer_storage(556
glow::RENDERBUFFER,557
glow::DEPTH_COMPONENT24,558
w as i32,559
h as i32,560
);561
gl.framebuffer_renderbuffer(562
glow::FRAMEBUFFER,563
glow::DEPTH_ATTACHMENT,564
glow::RENDERBUFFER,565
Some(d),566
);567
gl.draw_buffers(&[568
glow::COLOR_ATTACHMENT0,569
glow::COLOR_ATTACHMENT1,570
glow::COLOR_ATTACHMENT2,571
]);572
if gl.check_framebuffer_status(glow::FRAMEBUFFER) != glow::FRAMEBUFFER_COMPLETE {573
return Err("productie-FBO onvolledig".into());574
}575
self.extent = [w, h];576
Ok(())577
}578
/// Render and read back unlit color, view-space normals, scalar specular and579
/// the actual depth attachment. All four use the same winning surface.580
/// # Safety581
/// `gl` is current on the UI thread. GL state is reset for egui painting.582
pub unsafe fn render(&mut self, gl: &glow::Context, scene: &Scene) -> Result<Buffers, String> {583
if scene.width == 0584
|| scene.height == 0585
|| scene.width > MAX_HIGHRES586
|| scene.height > MAX_HIGHRES587
{588
return Err("highres moet 1–2048 pixels zijn (8× target)".into());589
}590
scene.validate()?;591
let previous = gl.get_parameter_i32(glow::FRAMEBUFFER_BINDING);592
let mut vp = [0; 4];593
gl.get_parameter_i32_slice(glow::VIEWPORT, &mut vp);594
let result = (|| {595
self.init(gl)?;596
self.target(gl, scene.width, scene.height)?;597
gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);598
gl.draw_buffers(&[599
glow::COLOR_ATTACHMENT0,600
glow::COLOR_ATTACHMENT1,601
glow::COLOR_ATTACHMENT2,602
]);603
gl.viewport(0, 0, scene.width as i32, scene.height as i32);604
gl.disable(glow::SCISSOR_TEST);605
gl.disable(glow::CULL_FACE);606
gl.disable(glow::BLEND);607
gl.enable(glow::DEPTH_TEST);608
gl.depth_mask(true);609
gl.depth_func(glow::LESS);610
gl.clear_depth_f32(1.);611
gl.clear_color(0., 0., 0., 0.);612
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);613
let p = self.program.ok_or("shader ontbreekt")?;614
gl.use_program(Some(p));615
gl.uniform_matrix_4_f32_slice(616
gl.get_uniform_location(p, "u_view").as_ref(),617
false,618
&scene.view,619
);620
gl.uniform_matrix_4_f32_slice(621
gl.get_uniform_location(p, "u_projection").as_ref(),622
false,623
&scene.projection,624
);625
gl.bind_vertex_array(self.vao);626
gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);627
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo);628
for (slot, size, offset) in [(0, 3, 0), (1, 3, 3), (2, 2, 6), (3, 4, 8)] {629
gl.enable_vertex_attrib_array(slot);630
gl.vertex_attrib_pointer_f32(slot, size, glow::FLOAT, false, 12 * 4, offset * 4);631
}632
for draw in &scene.draws {633
if draw.positions.len() != draw.normals.len()634
|| draw635
.indices636
.iter()637
.any(|i| *i as usize >= draw.positions.len())638
|| draw.indices.len() > i32::MAX as usize639
{640
return Err("ongeldige GPU-geometrie".into());641
}642
let mut data = Vec::with_capacity(draw.positions.len() * 12);643
for (i, pos) in draw.positions.iter().enumerate() {644
data.extend_from_slice(pos);645
data.extend_from_slice(&draw.normals[i]);646
data.extend_from_slice(&draw.uvs.get(i).copied().unwrap_or([0.; 2]));647
data.extend_from_slice(&draw.vertex_colors.get(i).copied().unwrap_or([1.; 4]));648
}649
gl.buffer_data_u8_slice(650
glow::ARRAY_BUFFER,651
std::slice::from_raw_parts(data.as_ptr().cast(), data.len() * 4),652
glow::STREAM_DRAW,653
);654
gl.buffer_data_u8_slice(655
glow::ELEMENT_ARRAY_BUFFER,656
std::slice::from_raw_parts(657
draw.indices.as_ptr().cast(),658
draw.indices.len() * 4,659
),660
glow::STREAM_DRAW,661
);662
gl.uniform_4_f32(663
gl.get_uniform_location(p, "u_color").as_ref(),664
draw.color[0],665
draw.color[1],666
draw.color[2],667
draw.color[3],668
);669
gl.uniform_1_f32(670
gl.get_uniform_location(p, "u_specular").as_ref(),671
draw.specular,672
);673
gl.uniform_1_f32(gl.get_uniform_location(p, "u_cutoff").as_ref(), draw.cutoff);674
gl.uniform_1_i32(675
gl.get_uniform_location(p, "u_flat").as_ref(),676
i32::from(draw.flat_normals),677
);678
let mut temp = Vec::new();679
for (unit, name, texture) in [680
(0, "u_albedo", draw.albedo.as_ref()),681
(1, "u_shiny", draw.shiny.as_ref()),682
] {683
gl.active_texture(glow::TEXTURE0 + unit);684
gl.uniform_1_i32(gl.get_uniform_location(p, name).as_ref(), unit as i32);685
gl.uniform_1_i32(686
gl.get_uniform_location(p, &format!("{name}_enabled"))687
.as_ref(),688
i32::from(texture.is_some()),689
);690
if let Some(image) = texture {691
let t = gl.create_texture()?;692
temp.push(t);693
gl.bind_texture(glow::TEXTURE_2D, Some(t));694
gl.tex_parameter_i32(695
glow::TEXTURE_2D,696
glow::TEXTURE_MIN_FILTER,697
glow::NEAREST as i32,698
);699
gl.tex_parameter_i32(700
glow::TEXTURE_2D,701
glow::TEXTURE_MAG_FILTER,702
glow::NEAREST as i32,703
);704
gl.tex_parameter_i32(705
glow::TEXTURE_2D,706
glow::TEXTURE_WRAP_S,707
glow::REPEAT as i32,708
);709
gl.tex_parameter_i32(710
glow::TEXTURE_2D,711
glow::TEXTURE_WRAP_T,712
glow::REPEAT as i32,713
);714
gl.tex_image_2d(715
glow::TEXTURE_2D,716
0,717
if unit == 0 {718
glow::SRGB8_ALPHA8 as i32719
} else {720
glow::RGBA8 as i32721
},722
image.width as i32,723
image.height as i32,724
0,725
glow::RGBA,726
glow::UNSIGNED_BYTE,727
Some(&image.rgba),728
);729
} else {730
gl.bind_texture(glow::TEXTURE_2D, None);731
}732
}733
gl.draw_elements(734
glow::TRIANGLES,735
draw.indices.len() as i32,736
glow::UNSIGNED_INT,737
0,738
);739
for t in temp {740
gl.delete_texture(t);741
}742
}743
let pixels = scene.width as usize * scene.height as usize;744
let mut maps = Vec::new();745
gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1);746
for slot in 0..3 {747
let mut data = vec![0; pixels * 4];748
gl.read_buffer(glow::COLOR_ATTACHMENT0 + slot);749
gl.read_pixels(750
0,751
0,752
scene.width as i32,753
scene.height as i32,754
glow::RGBA,755
glow::UNSIGNED_BYTE,756
glow::PixelPackData::Slice(&mut data),757
);758
flip_rows(&mut data, scene.width as usize * 4, scene.height as usize);759
maps.push(data);760
}761
let mut depth = vec![1f32; pixels];762
let bytes = std::slice::from_raw_parts_mut(depth.as_mut_ptr().cast(), pixels * 4);763
gl.read_pixels(764
0,765
0,766
scene.width as i32,767
scene.height as i32,768
glow::DEPTH_COMPONENT,769
glow::FLOAT,770
glow::PixelPackData::Slice(bytes),771
);772
flip_rows(&mut depth, scene.width as usize, scene.height as usize);773
let error = gl.get_error();774
if error != glow::NO_ERROR {775
return Err(format!("productie-GL fout 0x{error:x}"));776
}777
let specular = maps.pop().unwrap();778
let normal = maps.pop().unwrap();779
let diffuse = maps.pop().unwrap();780
Ok(Buffers {781
width: scene.width,782
height: scene.height,783
diffuse,784
normal,785
specular,786
depth,787
})788
})();789
gl.bind_framebuffer(790
glow::FRAMEBUFFER,791
std::num::NonZeroU32::new(previous as u32).map(glow::NativeFramebuffer),792
);793
gl.viewport(vp[0], vp[1], vp[2], vp[3]);794
gl.bind_vertex_array(None);795
gl.use_program(None);796
gl.active_texture(glow::TEXTURE0);797
gl.disable(glow::DEPTH_TEST);798
gl.disable(glow::SCISSOR_TEST);799
gl.enable(glow::BLEND);800
gl.pixel_store_i32(glow::PACK_ALIGNMENT, 4);801
result802
}803
}804
fn flip_rows<T>(data: &mut [T], stride: usize, height: usize) {805
for y in 0..height / 2 {806
let split = (height - 1 - y) * stride;807
let (a, b) = data.split_at_mut(split);808
a[y * stride..(y + 1) * stride].swap_with_slice(&mut b[..stride]);809
}810
}811
const VERTEX: &str = r#"#version 330812
layout(location=0)in vec3 a_position;layout(location=1)in vec3 a_normal;layout(location=2)in vec2 a_uv;layout(location=3)in vec4 a_color;813
uniform mat4 u_view;uniform mat4 u_projection;out vec3 v_normal;out vec3 v_view_position;out vec2 v_uv;out vec4 v_color;814
void main(){v_color=a_color;v_normal=mat3(u_view)*a_normal;v_uv=a_uv;vec4 position=u_view*vec4(a_position,1.0);v_view_position=position.xyz;gl_Position=u_projection*position;}815
"#;816
const FRAGMENT: &str = r#"#version 330817
in vec3 v_normal;in vec3 v_view_position;uniform bool u_flat;in vec2 v_uv;in vec4 v_color;uniform vec4 u_color;uniform float u_cutoff;uniform vec3 u_light_direction; // deliberately unused: production maps are unlit818
uniform float u_specular;819
uniform sampler2D u_albedo;uniform sampler2D u_shiny;uniform bool u_albedo_enabled;uniform bool u_shiny_enabled;820
layout(location=0)out vec4 diffuse;layout(location=1)out vec4 normal;layout(location=2)out vec4 specular;821
vec3 encode_srgb(vec3 x){return mix(x*12.92,1.055*pow(max(x,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),x));}822
void main(){vec3 n=normalize(v_normal);if(u_flat){n=normalize(cross(dFdx(v_view_position),dFdy(v_view_position)));if(!gl_FrontFacing)n=-n;}vec4 c=u_color*v_color;if(u_albedo_enabled)c*=texture(u_albedo,v_uv);if(c.a<u_cutoff)discard;float s=u_specular;if(u_shiny_enabled)s*=dot(texture(u_shiny,v_uv).rgb,vec3(0.2126,0.7152,0.0722));diffuse=vec4(encode_srgb(clamp(c.rgb,0.0,1.0)),c.a);normal=vec4(n*0.5+0.5,c.a);specular=vec4(vec3(clamp(s,0.0,1.0)),c.a);}823
"#;824
825
fn image(w: u32, h: u32, rgba: Vec<u8>) -> ImageBuf {826
ImageBuf {827
width: w,828
height: h,829
rgba,830
}831
}832
fn linear(x: u8) -> f32 {833
static LUT: std::sync::OnceLock<[f32; 256]> = std::sync::OnceLock::new();834
LUT.get_or_init(|| {835
std::array::from_fn(|i| {836
let v = i as f32 / 255.;837
if v <= 0.04045 {838
v / 12.92839
} else {840
((v + 0.055) / 1.055).powf(2.4)841
}842
})843
})[x as usize]844
}845
846
fn srgb(x: f32) -> u8 {847
let v = if x <= 0.0031308 {848
x * 12.92849
} else {850
1.055 * x.powf(1. / 2.4) - 0.055851
};852
(v * 255.).round().clamp(0., 255.) as u8853
}854
impl Buffers {855
pub fn validate(&self) -> Result<(), String> {856
let pixels = (self.width as usize)857
.checked_mul(self.height as usize)858
.ok_or("te veel pixels")?;859
if self.width == 0860
|| self.height == 0861
|| self.width > MAX_HIGHRES862
|| self.height > MAX_HIGHRES863
|| self.diffuse.len() != pixels * 4864
|| self.normal.len() != pixels * 4865
|| self.specular.len() != pixels * 4866
|| self.depth.len() != pixels867
|| self868
.depth869
.iter()870
.any(|d| !d.is_finite() || !(0.0..=1.0).contains(d))871
{872
return Err("ongeldige GPU-mapafmetingen/data".into());873
}874
Ok(())875
}876
}877
pub fn detail_map(raw: &Buffers) -> Result<Vec<u8>, String> {878
raw.validate()?;879
let w = raw.width as usize;880
let h = raw.height as usize;881
let mut out = vec![0; w * h];882
for y in 0..h {883
for x in 0..w {884
let i = y * w + x;885
let mut edge = 0f32;886
for (nx, ny) in [887
(x.saturating_sub(1), y),888
((x + 1).min(w - 1), y),889
(x, y.saturating_sub(1)),890
(x, (y + 1).min(h - 1)),891
] {892
let j = ny * w + nx;893
let a = raw.diffuse[i * 4 + 3] as f32 / 255.;894
let b = raw.diffuse[j * 4 + 3] as f32 / 255.;895
edge = edge.max((a - b).abs());896
if a > 0. && b > 0. {897
for c in 0..3 {898
edge = edge.max(899
(linear(raw.diffuse[i * 4 + c]) - linear(raw.diffuse[j * 4 + c])).abs(),900
);901
edge = edge.max(902
(raw.normal[i * 4 + c] as f32 - raw.normal[j * 4 + c] as f32).abs()903
/ 255.,904
);905
}906
edge = edge.max(((raw.depth[i] - raw.depth[j]).abs() * 200.).min(1.));907
}908
}909
out[i] = (edge * 255.).round().clamp(0., 255.) as u8;910
}911
}912
Ok(out)913
}914
pub fn detail_image(raw: &Buffers, detail: &[u8]) -> ImageBuf {915
let mut rgba = Vec::with_capacity(detail.len() * 4);916
for (i, v) in detail.iter().enumerate() {917
rgba.extend_from_slice(&[*v, *v, *v, raw.diffuse[i * 4 + 3]]);918
}919
image(raw.width, raw.height, rgba)920
}921
pub fn resolve(raw: &Buffers, detail: &[u8], w: u32, h: u32) -> Result<Target, String> {922
raw.validate()?;923
if w == 0 || h == 0 || w > MAX_HIGHRES / SCALE || h > MAX_HIGHRES / SCALE {924
return Err("ongeldige target-afmetingen".into());925
}926
if raw.width != w * SCALE927
|| raw.height != h * SCALE928
|| detail.len() != raw.width as usize * raw.height as usize929
{930
return Err("ongeldige 8× resolve-afmetingen".into());931
}932
let count = w as usize * h as usize;933
let mut diffuse = vec![0; count * 4];934
let mut normals = diffuse.clone();935
let mut specular = diffuse.clone();936
let mut depth = vec![0u16; count * 2];937
let mut depth_preview = diffuse.clone();938
for y in 0..h {939
for x in 0..w {940
let i = (y * w + x) as usize;941
let mut closest = 1f32;942
let mut coverage = 0f32;943
for dy in 0..SCALE {944
for dx in 0..SCALE {945
let j = ((y * SCALE + dy) * raw.width + x * SCALE + dx) as usize;946
let a = raw.diffuse[j * 4 + 3] as f32 / 255.;947
coverage += a;948
if a > 0. && raw.depth[j].is_finite() {949
closest = closest.min(raw.depth[j]);950
}951
}952
}953
let alpha = (coverage / (SCALE * SCALE) as f32 * 255.)954
.round()955
.clamp(0., 255.) as u8;956
if alpha == 0 {957
continue;958
}959
let mut color = [0.; 3];960
let mut n = [0.; 3];961
let mut shiny = 0.;962
let mut sum = 0.;963
// One coherent front-surface cluster; never average foreground with a rear964
// surface. Edges get extra weight, and any thin covered feature survives.965
for dy in 0..SCALE {966
for dx in 0..SCALE {967
let j = ((y * SCALE + dy) * raw.width + x * SCALE + dx) as usize;968
let a = raw.diffuse[j * 4 + 3] as f32 / 255.;969
if a == 0. || raw.depth[j] > closest + 0.0025 {970
continue;971
}972
let weight = a * (1. + 4. * detail[j] as f32 / 255.);973
sum += weight;974
for c in 0..3 {975
color[c] += linear(raw.diffuse[j * 4 + c]) * weight;976
n[c] += (raw.normal[j * 4 + c] as f32 / 255. * 2. - 1.) * weight;977
}978
shiny += raw.specular[j * 4] as f32 * weight;979
}980
}981
if sum <= 0. {982
return Err("ongeldige depth/coverage in GPU-resultaat".into());983
}984
n = normalize(n);985
let d = (closest.clamp(0., 1.) * 65535.).round() as u16;986
for c in 0..3 {987
diffuse[i * 4 + c] = srgb(color[c] / sum);988
normals[i * 4 + c] = ((n[c] * 0.5 + 0.5) * 255.).round().clamp(0., 255.) as u8;989
specular[i * 4 + c] = (shiny / sum).round().clamp(0., 255.) as u8;990
depth_preview[i * 4 + c] = (d >> 8) as u8;991
}992
for map in [993
&mut diffuse,994
&mut normals,995
&mut specular,996
&mut depth_preview,997
] {998
map[i * 4 + 3] = alpha;999
}1000
depth[i * 2] = d;1001
depth[i * 2 + 1] = u16::from(alpha) * 257;1002
}1003
}1004
Ok(Target {1005
diffuse: image(w, h, diffuse),1006
normal: image(w, h, normals),1007
specular: image(w, h, specular),1008
depth,1009
depth_preview: image(w, h, depth_preview),1010
})1011
}1012
1013
fn safe_parent(root: &Path, relative: &Path) -> Result<PathBuf, String> {1014
let relative = relative1015
.to_str()1016
.ok_or("ongeldig uitvoerpad")?1017
.replace('\\', "/");1018
let relative = crate::project::portable_relative(&relative)?;1019
let relative = relative.as_str();1020
let canonical = root.canonicalize().map_err(|e| e.to_string())?;1021
let path = root.join(relative);1022
let parent = path.parent().ok_or("uitvoermap ontbreekt")?;1023
let mut prefix = root.to_path_buf();1024
for part in Path::new(relative)1025
.parent()1026
.unwrap_or(Path::new(""))1027
.components()1028
{1029
prefix.push(part);1030
if !prefix.exists() {1031
std::fs::create_dir(&prefix).map_err(|e| e.to_string())?;1032
}1033
let real = prefix.canonicalize().map_err(|e| e.to_string())?;1034
if !real.starts_with(&canonical) {1035
return Err("uitvoer verlaat projectmap".into());1036
}1037
}1038
if !parent1039
.canonicalize()1040
.map_err(|e| e.to_string())?1041
.starts_with(canonical)1042
{1043
return Err("onveilig uitvoerpad".into());1044
}1045
Ok(path)1046
}1047
fn atomic_file(1048
root: &Path,1049
relative: &Path,1050
write: impl FnOnce(&mut std::fs::File) -> Result<(), String>,1051
) -> Result<(), String> {1052
let path = safe_parent(root, relative)?;1053
let temp = path.with_extension(format!("part-{}", std::process::id()));1054
let mut owned = false;1055
let result = (|| {1056
let mut file = std::fs::OpenOptions::new()1057
.write(true)1058
.create_new(true)1059
.open(&temp)1060
.map_err(|e| e.to_string())?;1061
owned = true;1062
write(&mut file)?;1063
file.sync_all().map_err(|e| e.to_string())?;1064
std::fs::hard_link(&temp, &path).map_err(|e| e.to_string())?;1065
Ok(())1066
})();1067
if owned {1068
let _ = std::fs::remove_file(temp);1069
}1070
result1071
}1072
pub fn write_rgba(root: &Path, relative: &Path, img: &ImageBuf) -> Result<(), String> {1073
if img.width == 01074
|| img.height == 01075
|| img.width > MAX_HIGHRES / SCALE1076
|| img.height > MAX_HIGHRES / SCALE1077
|| img.rgba.len() != img.width as usize * img.height as usize * 41078
{1079
return Err("ongeldige PNG-afmetingen".into());1080
}1081
atomic_file(root, relative, |file| {1082
let mut encoder = png::Encoder::new(file, img.width, img.height);1083
encoder.set_color(png::ColorType::Rgba);1084
encoder.set_depth(png::BitDepth::Eight);1085
if relative1086
.file_name()1087
.and_then(|p| p.to_str())1088
.map(|p| p.ends_with("_diffuse.png"))1089
.unwrap_or(false)1090
{1091
encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);1092
}1093
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;1094
writer1095
.write_image_data(&img.rgba)1096
.map_err(|e| e.to_string())?;1097
writer.finish().map_err(|e| e.to_string())1098
})1099
}1100
pub fn write_depth(1101
root: &Path,1102
relative: &Path,1103
w: u32,1104
h: u32,1105
depth: &[u16],1106
) -> Result<(), String> {1107
if w == 01108
|| h == 01109
|| w > MAX_HIGHRES / SCALE1110
|| h > MAX_HIGHRES / SCALE1111
|| depth.len() != w as usize * h as usize * 21112
{1113
return Err("ongeldige 16-bit depthafmetingen".into());1114
}1115
let bytes: Vec<u8> = depth.iter().flat_map(|x| x.to_be_bytes()).collect();1116
atomic_file(root, relative, |file| {1117
let mut encoder = png::Encoder::new(file, w, h);1118
encoder.set_color(png::ColorType::GrayscaleAlpha);1119
encoder.set_depth(png::BitDepth::Sixteen);1120
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;1121
writer.write_image_data(&bytes).map_err(|e| e.to_string())?;1122
writer.finish().map_err(|e| e.to_string())1123
})1124
}1125
fn write_json(root: &Path, name: &str, value: serde_json::Value) -> Result<(), String> {1126
use std::io::Write;1127
let data = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?;1128
atomic_file(root, Path::new(name), |f| {1129
f.write_all(&data).map_err(|e| e.to_string())1130
})1131
}1132
1133
pub struct GpuRequest {1134
pub scene: Scene,1135
reply: mpsc::SyncSender<Result<Buffers, String>>,1136
}1137
impl GpuRequest {1138
pub fn fail(self, error: String) {1139
let _ = self.reply.send(Err(error));1140
}1141
/// # Safety1142
/// `gl` must be current on this UI thread.1143
pub unsafe fn service(self, renderer: &mut GpuRenderer, gl: &glow::Context) {1144
let result = renderer.render(gl, &self.scene);1145
let _ = self.reply.send(result);1146
}1147
}1148
#[derive(Default)]1149
struct Pending {1150
pass: Option<RenderEvent>,1151
progress: Option<RenderEvent>,1152
terminal: Option<RenderEvent>,1153
}1154
#[derive(Clone, Default)]1155
pub struct Mailbox(Arc<Mutex<Pending>>);1156
impl Mailbox {1157
pub fn publish(&self, event: RenderEvent) {1158
if let Ok(mut state) = self.0.lock() {1159
match event {1160
RenderEvent::Pass { .. } => {1161
state.pass = Some(event);1162
state.progress = None;1163
}1164
RenderEvent::Step { .. } => state.progress = Some(event),1165
_ => state.terminal = Some(event),1166
}1167
}1168
}1169
pub fn drain(&self) -> Vec<RenderEvent> {1170
let mut events = Vec::new();1171
if let Ok(mut s) = self.0.lock() {1172
events.extend(s.pass.take());1173
events.extend(s.progress.take());1174
events.extend(s.terminal.take());1175
}1176
events1177
}1178
}1179
pub struct RenderThread {1180
pub mailbox: Mailbox,1181
pub gpu: mpsc::Receiver<GpuRequest>,1182
pub cancel: Arc<AtomicBool>,1183
pub handle: Option<thread::JoinHandle<()>>,1184
}1185
impl Drop for RenderThread {1186
fn drop(&mut self) {1187
self.cancel.store(true, Ordering::Relaxed);1188
}1189
}1190
pub fn spawn(job: RenderJob, ctx: Option<eframe::egui::Context>) -> RenderThread {1191
let mailbox = Mailbox::default();1192
let output = mailbox.clone();1193
let cancel = Arc::new(AtomicBool::new(false));1194
let flag = cancel.clone();1195
let (tx, gpu) = mpsc::channel();1196
let handle = thread::spawn(move || {1197
let mut emit = |event| {1198
output.publish(event);1199
if let Some(ctx) = &ctx {1200
ctx.request_repaint();1201
}1202
};1203
let result = execute(job, &tx, &flag, ctx.as_ref(), &mut emit);1204
if let Err(error) = result {1205
if flag.load(Ordering::Relaxed) {1206
emit(RenderEvent::Cancelled);1207
} else {1208
emit(RenderEvent::Failed(error));1209
}1210
}1211
});1212
RenderThread {1213
mailbox,1214
gpu,1215
cancel,1216
handle: Some(handle),1217
}1218
}1219
fn execute(1220
mut job: RenderJob,1221
gpu: &mpsc::Sender<GpuRequest>,1222
cancel: &AtomicBool,1223
ctx: Option<&eframe::egui::Context>,1224
emit: &mut dyn FnMut(RenderEvent),1225
) -> Result<(), String> {1226
job.framing.validate()?;1227
if job.target_width == 01228
|| job.target_height == 01229
|| job.target_width > MAX_HIGHRES / SCALE1230
|| job.target_height > MAX_HIGHRES / SCALE1231
{1232
return Err("8× highres vereist targetafmetingen 1–256".into());1233
}1234
if !["male", "female"].contains(&job.sex.as_str()) {1235
return Err("ongeldig geslacht".into());1236
}1237
if let Some(p) = &job.preset {1238
p.validate()?;1239
if !p.applies[usize::from(job.sex == "female")] {1240
return Err("equipment niet geldig voor dit geslacht".into());1241
}1242
if p.attachments.is_empty() {1243
return Err("equipment heeft geen onderdelen".into());1244
}1245
}1246
let plan = plan(&job.character, job.fps)?;1247
let output_bytes =1248
u64::from(plan.images) * u64::from(job.target_width) * u64::from(job.target_height) * 16;1249
if output_bytes > 16 * 1024 * 1024 * 1024 {1250
return Err("batch groter dan 16 GiB ongecomprimeerde targetmaps".into());1251
}1252
std::fs::create_dir_all(&job.output).map_err(|e| e.to_string())?;1253
let stamp = std::time::SystemTime::now()1254
.duration_since(std::time::UNIX_EPOCH)1255
.map_err(|e| e.to_string())?1256
.as_nanos();1257
let root = job1258
.output1259
.join(format!("run-{stamp}-{}", std::process::id()));1260
std::fs::create_dir(&root).map_err(|e| e.to_string())?;1261
let set = job1262
.preset1263
.as_ref()1264
.map(|p| p.name.as_str())1265
.unwrap_or("body")1266
.to_string();1267
write_json(1268
&root,1269
"render.json",1270
serde_json::json!({"format_version":1,"status":"incomplete","sex":job.sex,"set":set,"target":[job.target_width,job.target_height],"fps":job.fps,"highres_scale":SCALE,"framing":job.framing,"near":NEAR,"far":FAR,"normal_space":"view (+Y up), PNG rows top-down","camera_distance":10,"yaw_zero":"camera on -Z","pitches":[-30,0,30],"yaw_step":30,"sampling":"clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained","alpha":"straight, opaque/cutout, coverage resolve","diffuse":"unlit sRGB albedo × vertex color, linear-light detail resolve","depth":"16-bit gray+alpha; distance=near+(sample/65535)*(far-near)","specular":"linear scalar; body defaults zero","pass_order":PASSES,"saved_maps":["diffuse","depth","normal","specular"],"clips":plan.clips.iter().enumerate().map(|(i,c)|serde_json::json!({"name":c.source_name,"token":c.token,"frames":c.frames,"duration":job.character.clips.get(i).map(|c|c.duration).unwrap_or(0.)})).collect::<Vec<_>>(),"images":plan.images,"passes":plan.passes,"png_files":plan.files}),1271
)?;1272
// Internal unique names select every source clip, even duplicate display names.1273
for (i, c) in job.character.clips.iter_mut().enumerate() {1274
c.name = format!("render-clip-{i}");1275
}1276
let mut completed = 0u32;1277
let mut cache = equipment::TextureCache::default();1278
let result = (|| {1279
for sample in samples(&plan) {1280
let clip = &plan.clips[sample.clip];1281
let frame = sample.frame;1282
let pitch = sample.pitch;1283
let yaw = sample.yaw;1284
let key = if job.character.clips.is_empty() {1285
String::new()1286
} else {1287
format!("render-clip-{}", sample.clip)1288
};1289
if cancel.load(Ordering::Relaxed) {1290
return Err("geannuleerd".into());1291
}1292
let pose = format!(1293
"{} {set} · {} f{frame}/{} · p{pitch} y{yaw}",1294
job.sex, clip.source_name, clip.frames1295
);1296
emit(RenderEvent::Step {1297
index: completed,1298
total: plan.passes,1299
label: format!("{pose} · highres"),1300
});1301
let scene = scene(1302
&job,1303
&key,1304
frame as f32 / job.fps as f32,1305
pitch as f32,1306
yaw as f32,1307
&mut cache,1308
)?;1309
let (tx, rx) = mpsc::sync_channel(1);1310
gpu.send(GpuRequest { scene, reply: tx })1311
.map_err(|_| "GPU-service gesloten")?;1312
if let Some(ctx) = ctx {1313
ctx.request_repaint();1314
}1315
let raw = loop {1316
match rx.recv_timeout(std::time::Duration::from_millis(100)) {1317
Ok(result) => break result?,1318
Err(mpsc::RecvTimeoutError::Timeout) => {1319
if cancel.load(Ordering::Relaxed) {1320
return Err("geannuleerd".into());1321
}1322
}1323
Err(_) => return Err("GPU-service gestopt".into()),1324
}1325
};1326
completed += 1;1327
emit(RenderEvent::Pass {1328
index: completed - 1,1329
total: plan.passes,1330
label: format!("{pose} · highres"),1331
image: Some(image(raw.width, raw.height, raw.diffuse.clone())),1332
});1333
if cancel.load(Ordering::Relaxed) {1334
return Err("geannuleerd".into());1335
}1336
emit(RenderEvent::Step {1337
index: completed,1338
total: plan.passes,1339
label: format!("{pose} · detail"),1340
});1341
let detail = detail_map(&raw)?;1342
completed += 1;1343
emit(RenderEvent::Pass {1344
index: completed - 1,1345
total: plan.passes,1346
label: format!("{pose} · detail"),1347
image: Some(detail_image(&raw, &detail)),1348
});1349
if cancel.load(Ordering::Relaxed) {1350
return Err("geannuleerd".into());1351
}1352
emit(RenderEvent::Step {1353
index: completed,1354
total: plan.passes,1355
label: format!("{pose} · target-res / diffuse"),1356
});1357
let target = resolve(&raw, &detail, job.target_width, job.target_height)?;1358
for (map, preview) in [1359
("diffuse", target.diffuse),1360
("depth", target.depth_preview),1361
("normal", target.normal),1362
("specular", target.specular),1363
] {1364
if cancel.load(Ordering::Relaxed) {1365
return Err("geannuleerd".into());1366
}1367
emit(RenderEvent::Step {1368
index: completed,1369
total: plan.passes,1370
label: format!("{pose} · {map}"),1371
});1372
let path = output_path(&job.sex, &set, &clip.token, frame, pitch, yaw, map)?;1373
if map == "depth" {1374
write_depth(1375
&root,1376
&path,1377
job.target_width,1378
job.target_height,1379
&target.depth,1380
)?;1381
} else {1382
write_rgba(&root, &path, &preview)?;1383
}1384
completed += 1;1385
emit(RenderEvent::Pass {1386
index: completed - 1,1387
total: plan.passes,1388
label: format!(1389
"{pose} · {}",1390
if map == "diffuse" {1391
"target-res / diffuse"1392
} else {1393
map1394
}1395
),1396
image: Some(preview),1397
});1398
}1399
}1400
Ok(())1401
})();1402
match result {1403
Ok(()) => {1404
write_json(1405
&root,1406
"complete.json",1407
serde_json::json!({"status":"complete","passes":completed,"png_files":plan.files}),1408
)?;1409
emit(RenderEvent::Finished {1410
saved: Some(root.display().to_string()),1411
});1412
Ok(())1413
}1414
Err(error) => {1415
let name = if cancel.load(Ordering::Relaxed) {1416
"cancelled.json"1417
} else {1418
"failed.json"1419
};1420
let _ = write_json(1421
&root,1422
name,1423
serde_json::json!({"error":error,"completed_passes":completed,"total_passes":plan.passes}),1424
);1425
Err(error)1426
}1427
}1428
}1429
1430
/// Real readback regression: nearest-surface depth/normal/specular, preserved1431
/// texture × vertex albedo/alpha and independence from opposed light directions.1432
/// # Safety1433
/// Run on a current disposable native context.1434
pub unsafe fn map_regression_probe(gl: &glow::Context) -> Result<(), String> {1435
let triangle = |z, normal, color, specular| Draw {1436
positions: Arc::new(vec![[-0.9, -0.9, z], [0.9, -0.9, z], [0., 0.9, z]]),1437
normals: Arc::new(vec![normal; 3]),1438
uvs: Arc::new(vec![[0.; 2]; 3]),1439
vertex_colors: Arc::new(Vec::new()),1440
indices: vec![0, 1, 2],1441
color,1442
albedo: None,1443
shiny: None,1444
specular,1445
cutoff: 0.001,1446
flat_normals: false,1447
};1448
let mut renderer = GpuRenderer::default();1449
let mut scene = Scene {1450
draws: vec![1451
triangle(-0.5, [0., 0., 1.], [1., 0., 0., 1.], 0.75),1452
triangle(0.5, [1., 0., 0.], [0., 1., 0., 1.], 0.),1453
],1454
view: preview::identity(),1455
projection: preview::identity(),1456
width: 32,1457
height: 32,1458
};1459
let result = (|| {1460
let raw = renderer.render(gl, &scene)?;1461
let i = 16 * 32 + 16;1462
if raw.diffuse[i * 4..i * 4 + 4] != [255, 0, 0, 255]1463
|| (raw.depth[i] - 0.25).abs() > 1e-51464
|| raw.normal[i * 4..i * 4 + 3] != [128, 128, 255]1465
|| raw.specular[i * 4].abs_diff(191) > 11466
{1467
return Err(format!(1468
"GPU map winner mismatch: color {:?} depth {} normal {:?} spec {}",1469
&raw.diffuse[i * 4..i * 4 + 4],1470
raw.depth[i],1471
&raw.normal[i * 4..i * 4 + 4],1472
raw.specular[i * 4]1473
));1474
}1475
scene.draws.reverse();1476
let reverse = renderer.render(gl, &scene)?;1477
if reverse.diffuse != raw.diffuse1478
|| reverse.normal != raw.normal1479
|| reverse.depth != raw.depth1480
{1481
return Err("GPU map winner depends on submission order".into());1482
}1483
scene.draws = vec![triangle(-0.5, [0., 0., 1.], [0.5, 0.75, 1., 1.], 0.)];1484
scene.draws[0].vertex_colors = Arc::new(vec![[0.5, 1., 0.5, 1.]; 3]);1485
scene.draws[0].albedo = Some(Arc::new(equipment::Texture {1486
content_hash: 99,1487
width: 1,1488
height: 1,1489
rgba: vec![128, 255, 64, 192],1490
}));1491
let expected = [1492
srgb(linear(128) * 0.25),1493
srgb(0.75),1494
srgb(linear(64) * 0.5),1495
192,1496
];1497
// Production intentionally has no lighting input. This optional conventional1498
// uniform is optimized out now; a future accidental lighting term using it1499
// must fail these actual GPU readbacks under opposed light directions.1500
for light in [[0., 0., 1.], [0., 0., -1.], [1., 0., 0.]] {1501
let p = renderer.program.ok_or("missing probe program")?;1502
gl.use_program(Some(p));1503
gl.uniform_3_f32(1504
gl.get_uniform_location(p, "u_light_direction").as_ref(),1505
light[0],1506
light[1],1507
light[2],1508
);1509
let output = renderer.render(gl, &scene)?;1510
let pixel = &output.diffuse[i * 4..i * 4 + 4];1511
if pixel.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1) {1512
return Err(format!("diffuse is lit or alpha/vertex/texture lost under {light:?}: {pixel:?} vs {expected:?}"));1513
}1514
}1515
let detail = detail_map(&raw)?;1516
let target = resolve(&raw, &detail, 4, 4)?;1517
if target.diffuse.width != 41518
|| target.depth.len() != 321519
|| target1520
.normal1521
.rgba1522
.chunks_exact(4)1523
.filter(|p| p[3] > 0)1524
.any(|p| {1525
let n = p[..3]1526
.iter()1527
.map(|x| (*x as f32 / 255. * 2. - 1.).powi(2))1528
.sum::<f32>()1529
.sqrt();1530
(n - 1.).abs() > 0.031531
})1532
{1533
return Err("target map dimensions/normals invalid".into());1534
}1535
let mut flat = Scene {1536
draws: vec![triangle(-0.5, [1., 0., 0.], [1., 0., 0., 1.], 0.)],1537
view: preview::identity(),1538
projection: preview::identity(),1539
width: 32,1540
height: 32,1541
};1542
flat.draws[0].flat_normals = true;1543
let derived = renderer.render(gl, &flat)?;1544
if derived.normal[i * 4..i * 4 + 3] != [128, 128, 255] {1545
return Err(format!(1546
"flat normals are not geometry-derived: {:?}",1547
&derived.normal[i * 4..i * 4 + 4]1548
));1549
}1550
println!("Production GPU maps PASS: nearest depth=0.25, normal=[128,128,255], specular=191, albedo texture×vertex={expected:?}, alpha preserved, diffuse identical under 3 opposed lights; target maps valid; missing source normals use actual GPU triangle derivatives");1551
Ok(())1552
})();1553
renderer.destroy(gl);1554
result1555
}1556
1557
#[cfg(test)]1558
mod tests {1559
use super::*;1560
struct Temp(PathBuf);1561
impl Temp {1562
fn new() -> Self {1563
let p = std::env::temp_dir().join(format!(1564
"paperdoll-production-{}-{}",1565
std::process::id(),1566
std::time::SystemTime::now()1567
.duration_since(std::time::UNIX_EPOCH)1568
.unwrap()1569
.as_nanos()1570
));1571
std::fs::create_dir(&p).unwrap();1572
Self(p)1573
}1574
}1575
impl Drop for Temp {1576
fn drop(&mut self) {1577
let _ = std::fs::remove_dir_all(&self.0);1578
}1579
}1580
fn body() -> PreviewCharacter {1581
preview::load_gltf_file(1582
&Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),1583
)1584
.unwrap()1585
}1586
fn raw(w: u32, h: u32) -> Buffers {1587
let n = (w * h) as usize;1588
Buffers {1589
width: w,1590
height: h,1591
diffuse: [255, 0, 0, 255].repeat(n),1592
normal: [128, 128, 255, 255].repeat(n),1593
specular: [191, 191, 191, 255].repeat(n),1594
depth: vec![0.25; n],1595
}1596
}1597
#[test]1598
fn job_counts_every_clip_frame_angle_and_exact_order() {1599
let mut b = body();1600
let mut c = b.clips[0].clone();1601
c.name = "../A \u{2603}".into();1602
c.duration = 0.5;1603
b.clips = vec![c.clone(), c];1604
let p = plan(&b, 3).unwrap();1605
assert_eq!((p.images, p.passes, p.files), (144, 864, 576));1606
let s = samples(&p).collect::<Vec<_>>();1607
assert_eq!(s.len(), 144);1608
assert_eq!(1609
s[0],1610
Sample {1611
clip: 0,1612
frame: 0,1613
pitch: -30,1614
yaw: 01615
}1616
);1617
assert_eq!(s[11].yaw, 330);1618
assert_eq!(s[12].pitch, 0);1619
assert_eq!(s[35].pitch, 30);1620
assert_eq!(s[36].frame, 1);1621
assert_eq!(s[72].clip, 1);1622
assert_ne!(p.clips[0].token, p.clips[1].token);1623
assert!(p.clips.iter().all(|c| equipment::valid_name(&c.token)));1624
assert_eq!(1625
PASSES,1626
[1627
"highres",1628
"detail",1629
"target-res / diffuse",1630
"depth",1631
"normal",1632
"specular"1633
]1634
);1635
b.clips.clear();1636
let p = plan(&b, 12).unwrap();1637
assert_eq!((p.images, p.files), (36, 144));1638
}1639
#[test]1640
fn floating_terminal_timestamp_does_not_duplicate_the_loop_endpoint() {1641
let mut b = body();1642
b.clips.truncate(1);1643
b.clips[0].duration = f32::from_bits(1f32.to_bits() + 1);1644
assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 2);1645
b.clips[0].duration = 1.00001;1646
assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 3);1647
}1648
#[test]1649
fn planner_rejects_invalid_or_unbounded_inputs() {1650
let mut b = body();1651
assert!(plan(&b, 0).is_err());1652
assert!(plan(&b, 241).is_err());1653
for duration in [f32::NAN, f32::INFINITY, -1., 10000.] {1654
b.clips[0].duration = duration;1655
assert!(plan(&b, 12).is_err());1656
}1657
}1658
#[test]1659
fn output_tokens_only_four_target_maps() {1660
let expected = PathBuf::from("male/body/clip000-walk/f000012/p-30_y330_diffuse.png");1661
assert_eq!(1662
output_path("male", "body", "clip000-walk", 12, -30, 330, "diffuse").unwrap(),1663
expected1664
);1665
for map in ["diffuse", "normal", "depth", "specular"] {1666
assert!(output_path("female", "sword", "clip000-bind", 0, 30, 0, map).is_ok());1667
}1668
for map in ["highres", "detail", "shiny", "../x"] {1669
assert!(output_path("male", "body", "clip000-bind", 0, 0, 0, map).is_err());1670
}1671
for token in ["../escape", "A", "a/b", "a\\b", ".", "a b"] {1672
assert!(output_path("male", token, "clip000-bind", 0, 0, 0, "diffuse").is_err());1673
}1674
assert!(output_path("male", "body", "walk", 0, 1, 0, "depth").is_err());1675
assert!(output_path("male", "body", "walk", 0, 0, 360, "depth").is_err());1676
}1677
#[test]1678
fn resolve_preserves_thin_foreground_and_matching_maps() {1679
let mut r = raw(8, 8);1680
r.diffuse = [0, 255, 0, 255].repeat(64);1681
r.depth.fill(0.75);1682
r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]);1683
r.depth[0] = 0.25;1684
let d = detail_map(&r).unwrap();1685
assert!(d.iter().any(|x| *x > 0));1686
let t = resolve(&r, &d, 1, 1).unwrap();1687
assert_eq!(t.diffuse.rgba, [255, 0, 0, 255]);1688
assert_eq!(t.specular.rgba, [191, 191, 191, 255]);1689
assert!((t.depth[0] as i32 - 16384).abs() <= 1);1690
let n = &t.normal.rgba;1691
let len = (0..3)1692
.map(|c| (n[c] as f32 / 255. * 2. - 1.).powi(2))1693
.sum::<f32>()1694
.sqrt();1695
assert!((len - 1.).abs() < 0.01);1696
r.diffuse.fill(0);1697
r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]);1698
let t = resolve(&r, &detail_map(&r).unwrap(), 1, 1).unwrap();1699
assert_eq!(t.diffuse.rgba[3], 4);1700
assert_eq!(t.depth[1], 1028);1701
assert_eq!(t.normal.rgba[3], 4);1702
assert_eq!(t.specular.rgba[3], 4);1703
}1704
#[test]1705
fn invalid_map_dimensions_and_depth_fail_without_panics() {1706
let mut r = raw(8, 8);1707
r.normal.pop();1708
assert!(detail_map(&r).is_err());1709
assert!(resolve(&r, &[0; 64], 1, 1).is_err());1710
let mut r = raw(8, 8);1711
r.depth[0] = f32::NAN;1712
assert!(resolve(&r, &[0; 64], 1, 1).is_err());1713
let r = raw(8, 8);1714
assert!(resolve(&r, &[0; 63], 1, 1).is_err());1715
assert!(resolve(&r, &[0; 64], 0, 1).is_err());1716
assert!(resolve(&r, &[0; 64], 257, 1).is_err());1717
}1718
#[test]1719
fn atomic_pngs_roundtrip_no_overwrite_or_temp_deletion() {1720
let t = Temp::new();1721
let p = Path::new("map_diffuse.png");1722
write_rgba(&t.0, p, &image(1, 1, vec![128, 64, 32, 192])).unwrap();1723
let original = std::fs::read(t.0.join(p)).unwrap();1724
assert!(write_rgba(&t.0, p, &image(1, 1, vec![0; 4])).is_err());1725
assert_eq!(std::fs::read(t.0.join(p)).unwrap(), original);1726
let mut reader = png::Decoder::new(std::io::Cursor::new(original))1727
.read_info()1728
.unwrap();1729
assert!(reader.info().srgb.is_some());1730
let mut out = vec![0; reader.output_buffer_size().unwrap()];1731
reader.next_frame(&mut out).unwrap();1732
assert_eq!(out, [128, 64, 32, 192]);1733
write_depth(&t.0, Path::new("depth.png"), 1, 1, &[16384, 32896]).unwrap();1734
let mut reader = png::Decoder::new(std::io::BufReader::new(1735
std::fs::File::open(t.0.join("depth.png")).unwrap(),1736
))1737
.read_info()1738
.unwrap();1739
assert_eq!(reader.info().bit_depth, png::BitDepth::Sixteen);1740
assert_eq!(reader.info().color_type, png::ColorType::GrayscaleAlpha);1741
assert!(reader.info().srgb.is_none());1742
let mut out = vec![0; reader.output_buffer_size().unwrap()];1743
reader.next_frame(&mut out).unwrap();1744
assert_eq!(out, [64, 0, 128, 128]);1745
let tmp =1746
t.0.join("blocked.png")1747
.with_extension(format!("part-{}", std::process::id()));1748
std::fs::write(&tmp, b"keep").unwrap();1749
assert!(write_rgba(&t.0, Path::new("blocked.png"), &image(1, 1, vec![0; 4])).is_err());1750
assert_eq!(std::fs::read(tmp).unwrap(), b"keep");1751
assert!(write_depth(&t.0, Path::new("bad.png"), 1, 1, &[0]).is_err());1752
assert!(write_rgba(&t.0, Path::new("bad.png"), &image(257, 1, vec![0; 1028])).is_err());1753
assert!(write_rgba(&t.0, Path::new("../escape.png"), &image(1, 1, vec![0; 4])).is_err());1754
}1755
#[cfg(unix)]1756
#[test]1757
fn output_rejects_escaping_symlinks() {1758
let root = Temp::new();1759
let outside = Temp::new();1760
std::os::unix::fs::symlink(&outside.0, root.0.join("link")).unwrap();1761
assert!(write_rgba(1762
&root.0,1763
Path::new("link/sub/x.png"),1764
&image(1, 1, vec![0; 4])1765
)1766
.is_err());1767
assert!(!outside.0.join("sub").exists());1768
}1769
#[test]1770
fn latest_mailbox_is_bounded_and_retains_terminal() {1771
let m = Mailbox::default();1772
for i in 0..1000 {1773
m.publish(RenderEvent::Pass {1774
index: i,1775
total: 1000,1776
label: i.to_string(),1777
image: Some(image(1, 1, vec![0; 4])),1778
});1779
}1780
m.publish(RenderEvent::Step {1781
index: 999,1782
total: 1000,1783
label: "working".into(),1784
});1785
m.publish(RenderEvent::Cancelled);1786
let events = m.drain();1787
assert_eq!(events.len(), 3);1788
assert!(matches!(&events[0], RenderEvent::Pass { index: 999, .. }));1789
assert!(matches!(&events[2], RenderEvent::Cancelled));1790
assert!(m.drain().is_empty());1791
}1792
1793
#[test]1794
fn fixed_body_and_equipment_camera_agree_and_body_is_not_a_draw() {1795
let temp = Temp::new();1796
let mut job = RenderJob {1797
character: body(),1798
sex: "male".into(),1799
preset: None,1800
target_width: 64,1801
target_height: 96,1802
fps: 12,1803
framing: CameraFraming {1804
height_milli: 3100,1805
center_y_milli: 1200,1806
},1807
output: temp.0.clone(),1808
};1809
let mut cache = equipment::TextureCache::default();1810
let b = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap();1811
let mut preset = equipment::Preset::new("sword".into());1812
let fixture =1813
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword.obj");1814
equipment::import_obj(&mut preset, &fixture).unwrap();1815
let joint = job1816
.character1817
.joint_names1818
.iter()1819
.position(|n| n == "RightHand")1820
.unwrap();1821
preset.bind(0, &job.character, 0, joint).unwrap();1822
let count = preset1823
.attachments1824
.iter()1825
.map(|a| a.meshes.len())1826
.sum::<usize>();1827
job.preset = Some(preset);1828
let e = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap();1829
assert_eq!(e.draws.len(), count);1830
assert_eq!(b.view, e.view);1831
assert_eq!(b.projection, e.projection);1832
assert_eq!((e.width, e.height), (512, 768));1833
assert_eq!(e.projection[5], 2. / 3.1);1834
let center = point(&e.view, [0., 1.2, 0.]);1835
assert!(center[0].abs() < 0.0001 && center[1].abs() < 0.0001);1836
assert!((center[2] + 10.).abs() < 0.0001);1837
let early = scene(&job, "attack", 0., 30., 90., &mut cache).unwrap();1838
let late = scene(&job, "attack", 0.5, 30., 90., &mut cache).unwrap();1839
assert_eq!(early.view, late.view);1840
assert_eq!(early.projection, late.projection);1841
assert_ne!(early.draws[0].positions, late.draws[0].positions);1842
let mut invalid = e.clone();1843
invalid.view[0] = f32::NAN;1844
assert!(invalid.validate().is_err());1845
}1846
#[test]1847
fn body_import_retains_texture_material_and_linear_vertex_color() {1848
let temp = Temp::new();1849
let source = std::fs::read(1850
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),1851
)1852
.unwrap();1853
let glb = gltf::binary::Glb::from_slice(&source).unwrap();1854
let mut json: serde_json::Value = serde_json::from_slice(&glb.json).unwrap();1855
let mut bin = glb.bin.unwrap().into_owned();1856
while !bin.len().is_multiple_of(4) {1857
bin.push(0);1858
}1859
let position = json["meshes"][0]["primitives"][0]["attributes"]["POSITION"]1860
.as_u64()1861
.unwrap() as usize;1862
let count = json["accessors"][position]["count"].as_u64().unwrap() as usize;1863
let offset = bin.len();1864
for _ in 0..count {1865
for value in [0.25f32, 0.75, 0.5, 0.5] {1866
bin.extend(value.to_le_bytes());1867
}1868
}1869
let view = json["bufferViews"].as_array().unwrap().len();1870
json["bufferViews"]1871
.as_array_mut()1872
.unwrap()1873
.push(serde_json::json!({"buffer":0,"byteOffset":offset,"byteLength":count*16}));1874
let accessor = json["accessors"].as_array().unwrap().len();1875
json["accessors"].as_array_mut().unwrap().push(1876
serde_json::json!({"bufferView":view,"componentType":5126,"count":count,"type":"VEC4"}),1877
);1878
json["meshes"][0]["primitives"][0]["attributes"]["COLOR_0"] = serde_json::json!(accessor);1879
json["buffers"][0] = serde_json::json!({"uri":"geometry.bin","byteLength":bin.len()});1880
json["images"] = serde_json::json!([{"uri":"albedo.png"}]);1881
json["textures"] = serde_json::json!([{"source":0}]);1882
let material = json["meshes"][0]["primitives"][0]["material"]1883
.as_u64()1884
.unwrap() as usize;1885
json["materials"][material]["pbrMetallicRoughness"]["baseColorTexture"] =1886
serde_json::json!({"index":0});1887
json["materials"][material]["pbrMetallicRoughness"]["baseColorFactor"] =1888
serde_json::json!([0.5, 0.75, 1., 1.]);1889
std::fs::write(temp.0.join("geometry.bin"), bin).unwrap();1890
std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap();1891
write_rgba(1892
&temp.0,1893
Path::new("albedo.png"),1894
&image(1, 1, vec![128, 255, 64, 192]),1895
)1896
.unwrap();1897
let body = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap();1898
assert_eq!(body.colors[0], [0.25, 0.75, 0.5, 0.5]);1899
assert_eq!(body.surfaces[0].color, [0.5, 0.75, 1., 1.]);1900
assert_eq!(1901
body.surfaces[0].albedo.as_ref().unwrap().rgba,1902
[128, 255, 64, 192]1903
);1904
assert_eq!(body.colors.len(), body.positions.len());1905
assert_eq!(body.uvs.len(), body.positions.len());1906
assert!(body.surfaces[0].flat_normals);1907
json["meshes"][0]["primitives"][0]["attributes"]1908
.as_object_mut()1909
.unwrap()1910
.remove("NORMAL");1911
std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap();1912
let missing = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap();1913
assert!(!missing.source_normals_complete);1914
let job = RenderJob {1915
character: missing,1916
sex: "male".into(),1917
preset: None,1918
target_width: 1,1919
target_height: 1,1920
fps: 12,1921
framing: CameraFraming::default(),1922
output: temp.0.clone(),1923
};1924
assert!(1925
scene(1926
&job,1927
"",1928
0.,1929
0.,1930
0.,1931
&mut equipment::TextureCache::default()1932
)1933
.unwrap()1934
.draws[0]1935
.flat_normals1936
);1937
}1938
#[test]1939
fn complete_and_cancelled_batches_have_truthful_atomic_outputs() {1940
for stop in [false, true] {1941
let tmp = Temp::new();1942
let mut b = body();1943
b.clips.clear();1944
let job = RenderJob {1945
character: b,1946
sex: "male".into(),1947
preset: None,1948
target_width: 1,1949
target_height: 1,1950
fps: 12,1951
framing: CameraFraming::default(),1952
output: tmp.0.clone(),1953
};1954
let flag = AtomicBool::new(false);1955
let (tx, rx) = mpsc::channel::<GpuRequest>();1956
let gpu = std::thread::spawn(move || {1957
for request in rx {1958
let _ = request1959
.reply1960
.send(Ok(raw(request.scene.width, request.scene.height)));1961
}1962
});1963
let mut labels = Vec::new();1964
let result = execute(job, &tx, &flag, None, &mut |event| {1965
if let RenderEvent::Pass { label, .. } = event {1966
labels.push(label);1967
if stop && labels.len() == 3 {1968
flag.store(true, Ordering::Relaxed);1969
}1970
}1971
});1972
drop(tx);1973
gpu.join().unwrap();1974
let run = std::fs::read_dir(&tmp.0)1975
.unwrap()1976
.next()1977
.unwrap()1978
.unwrap()1979
.path();1980
assert!(run.join("render.json").exists());1981
assert_eq!(run.join("complete.json").exists(), !stop);1982
assert_eq!(run.join("cancelled.json").exists(), stop);1983
assert_eq!(result.is_ok(), !stop);1984
let frame = run.join("male/body/clip000-bind/f000000");1985
assert!(frame.join("p-30_y000_diffuse.png").exists());1986
assert_eq!(frame.join("p-30_y000_depth.png").exists(), !stop);1987
assert!(!frame.join("p-30_y000_highres.png").exists());1988
assert!(!frame.join("p-30_y000_detail.png").exists());1989
if !stop {1990
assert_eq!(labels.len(), 216);1991
for labels in labels.chunks_exact(6) {1992
for (label, pass) in labels.iter().zip([1993
"highres",1994
"detail",1995
"target-res / diffuse",1996
"depth",1997
"normal",1998
"specular",1999
]) {2000
assert!(label.ends_with(pass));2001
}2002
}2003
}2004
}2005
}2006
}@@ -2,7 +2,7 @@
2
2
//!
3
3
//! Version 1 stores paths and render settings. It does not import models and
4
4
//! it does not render paperdolls. Unknown JSON fields are rejected so a save
5
//! cannot drop them. A newer or older `format_version` is rejected first.
5
//! cannot drop them. Schema 1 migrates to 2; unsupported `format_version` values are rejected first.
6
6
7
7
use std::fs::{self, File};
8
8
use std::io::Write;
@@ -12,7 +12,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
12
12
use serde::{Deserialize, Serialize};
13
13
use serde_json::Value;
14
14
15
pub const FORMAT_VERSION: u32 = 1;
15
pub const FORMAT_VERSION: u32 = 2;
16
16
pub const MANIFEST_NAME: &str = "character.ppd";
17
17
pub const MAX_PROJECT_PRESETS: usize = 64;
18
18
pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
@@ -28,12 +28,40 @@ const PROJECT_DIRS: [&str; 5] = [
28
28
"renders",
29
29
];
30
30
31
/// Persisted orthographic camera; integers keep project equality exact.
32
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33
#[serde(deny_unknown_fields)]
34
pub struct CameraFraming {
35
pub height_milli: u32,
36
pub center_y_milli: i32,
37
}
38
impl Default for CameraFraming {
39
fn default() -> Self {
40
Self {
41
height_milli: 2600,
42
center_y_milli: 1000,
43
}
44
}
45
}
46
impl CameraFraming {
47
pub fn validate(self) -> Result<(), String> {
48
if !(100..=10000).contains(&self.height_milli) || self.center_y_milli.unsigned_abs() > 10000
49
{
50
Err("renderkader: hoogte 0.1–10 m en centrum -10–10 m".into())
51
} else {
52
Ok(())
53
}
54
}
55
}
56
31
57
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32
58
#[serde(deny_unknown_fields)]
33
59
pub struct RenderSettings {
34
60
pub target_width: u32,
35
61
pub target_height: u32,
36
62
pub fps: u32,
63
#[serde(default)]
64
pub framing: [CameraFraming; 2],
37
65
}
38
66
39
67
impl Default for RenderSettings {
@@ -42,6 +70,7 @@ impl Default for RenderSettings {
42
70
target_width: 128,
43
71
target_height: 128,
44
72
fps: 12,
73
framing: [CameraFraming::default(); 2],
45
74
}
46
75
}
47
76
}
@@ -133,6 +162,12 @@ impl ProjectFile {
133
162
134
163
pub fn validate_shape(&self) -> Result<(), ProjectError> {
135
164
let mut issues = Vec::new();
165
for camera in self.render.framing {
166
if let Err(error) = camera.validate() {
167
issues.push(error);
168
}
169
}
170
136
171
if self.format_version != FORMAT_VERSION {
137
172
issues.push(format!(
138
173
"dit project gebruikt format_version {}; deze build leest alleen versie {FORMAT_VERSION}",
@@ -271,6 +306,7 @@ pub fn assemble(draft: ProjectDraft) -> Result<ProjectFile, ProjectError> {
271
306
target_width: draft.target_width,
272
307
target_height: draft.target_height,
273
308
fps: draft.fps,
309
framing: [CameraFraming::default(); 2],
274
310
},
275
311
};
276
312
document.validate_shape()?;
@@ -293,12 +329,14 @@ pub fn parse_manifest(text: &str) -> Result<ProjectFile, ProjectError> {
293
329
"format_version moet een geheel getal zijn".to_string(),
294
330
));
295
331
};
296
if version != u64::from(FORMAT_VERSION) {
332
if version != 1 && version != u64::from(FORMAT_VERSION) {
297
333
return Err(ProjectError::UnsupportedVersion {
298
334
found: version,
299
335
supported: FORMAT_VERSION,
300
336
});
301
337
}
338
let mut value = value;
339
value["format_version"] = serde_json::json!(FORMAT_VERSION);
302
340
serde_json::from_value(value).map_err(|err| {
303
341
let detail = err.to_string();
304
342
if detail.contains("unknown field") {
@@ -772,16 +810,16 @@ mod tests {
772
810
773
811
#[test]
774
812
fn newer_version_and_unknown_field_are_recoverable_parse_errors() {
775
let newer = r#"{"format_version":2,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
813
let newer = r#"{"format_version":3,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
776
814
let error = parse_manifest(newer).unwrap_err();
777
815
assert!(matches!(
778
816
error,
779
817
ProjectError::UnsupportedVersion {
780
found: 2,
781
supported: 1
818
found: 3,
819
supported: 2
782
820
}
783
821
));
784
assert!(error.to_string().contains("format_version 2"));
822
assert!(error.to_string().contains("format_version 3"));
785
823
let unknown = r#"{"format_version":1,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
786
824
let error = parse_manifest(unknown).unwrap_err();
787
825
assert!(matches!(error, ProjectError::UnknownField(_)));
@@ -840,6 +878,7 @@ mod tests {
840
878
target_width: 4096,
841
879
target_height: 4096,
842
880
fps: 12,
881
framing: [CameraFraming::default(); 2],
843
882
};
844
883
assert!(document
845
884
.validate_shape()
@@ -889,3 +928,69 @@ mod tests {
889
928
let _ = fs::remove_dir_all(&outside);
890
929
}
891
930
}
931
932
#[cfg(test)]
933
mod framing_tests {
934
use super::*;
935
#[test]
936
fn per_sex_fixed_framing_roundtrip_and_v1_migration() {
937
let mut p = assemble(ProjectDraft {
938
id: "fixed-framing".into(),
939
name: "Frames".into(),
940
male_model: String::new(),
941
female_model: String::new(),
942
equipment_presets: Vec::new(),
943
output_directory: "renders".into(),
944
target_width: 64,
945
target_height: 96,
946
fps: 12,
947
})
948
.unwrap();
949
p.render.framing = [
950
CameraFraming {
951
height_milli: 3210,
952
center_y_milli: 1100,
953
},
954
CameraFraming {
955
height_milli: 2890,
956
center_y_milli: 987,
957
},
958
];
959
let root = std::env::temp_dir().join(format!(
960
"paperdoll-framing-{}-{}",
961
std::process::id(),
962
std::time::SystemTime::now()
963
.duration_since(std::time::UNIX_EPOCH)
964
.unwrap()
965
.as_nanos()
966
));
967
let mut opened = create_project(&root, "Fixed framing").unwrap();
968
opened.document.render.framing = p.render.framing;
969
save_project(&opened).unwrap();
970
assert_eq!(
971
open_project(&opened.manifest_path)
972
.unwrap()
973
.document
974
.render
975
.framing,
976
p.render.framing
977
);
978
std::fs::remove_dir_all(root).unwrap();
979
let mut value = serde_json::to_value(&p).unwrap();
980
let text = serde_json::to_string(&value).unwrap();
981
assert_eq!(
982
parse_manifest(&text).unwrap().render.framing,
983
p.render.framing
984
);
985
value["format_version"] = serde_json::json!(1);
986
value["render"].as_object_mut().unwrap().remove("framing");
987
let migrated = parse_manifest(&value.to_string()).unwrap();
988
assert_eq!(migrated.format_version, 2);
989
assert_eq!(migrated.render.framing, [CameraFraming::default(); 2]);
990
value["render"]["framing"] = serde_json::json!([{"height_milli":2600,"center_y_milli":-2147483648},{"height_milli":2600,"center_y_milli":1000}]);
991
assert!(parse_manifest(&value.to_string())
992
.unwrap()
993
.validate_shape()
994
.is_err());
995
}
996
}
@@ -2,7 +2,7 @@2
//!2
//!3
//! Version 1 stores paths and render settings. It does not import models and3
//! Version 1 stores paths and render settings. It does not import models and4
//! it does not render paperdolls. Unknown JSON fields are rejected so a save4
//! it does not render paperdolls. Unknown JSON fields are rejected so a save5
//! cannot drop them. A newer or older `format_version` is rejected first.5
//! cannot drop them. Schema 1 migrates to 2; unsupported `format_version` values are rejected first.6
6
7
use std::fs::{self, File};7
use std::fs::{self, File};8
use std::io::Write;8
use std::io::Write;@@ -12,7 +12,7 @@ use std::time::{SystemTime, UNIX_EPOCH};12
use serde::{Deserialize, Serialize};12
use serde::{Deserialize, Serialize};13
use serde_json::Value;13
use serde_json::Value;14
14
15
pub const FORMAT_VERSION: u32 = 1;15
pub const FORMAT_VERSION: u32 = 2;16
pub const MANIFEST_NAME: &str = "character.ppd";16
pub const MANIFEST_NAME: &str = "character.ppd";17
pub const MAX_PROJECT_PRESETS: usize = 64;17
pub const MAX_PROJECT_PRESETS: usize = 64;18
pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;18
pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;@@ -28,12 +28,40 @@ const PROJECT_DIRS: [&str; 5] = [28
"renders",28
"renders",29
];29
];30
30
31
/// Persisted orthographic camera; integers keep project equality exact.32
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]33
#[serde(deny_unknown_fields)]34
pub struct CameraFraming {35
pub height_milli: u32,36
pub center_y_milli: i32,37
}38
impl Default for CameraFraming {39
fn default() -> Self {40
Self {41
height_milli: 2600,42
center_y_milli: 1000,43
}44
}45
}46
impl CameraFraming {47
pub fn validate(self) -> Result<(), String> {48
if !(100..=10000).contains(&self.height_milli) || self.center_y_milli.unsigned_abs() > 1000049
{50
Err("renderkader: hoogte 0.1–10 m en centrum -10–10 m".into())51
} else {52
Ok(())53
}54
}55
}56
31
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]57
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]32
#[serde(deny_unknown_fields)]58
#[serde(deny_unknown_fields)]33
pub struct RenderSettings {59
pub struct RenderSettings {34
pub target_width: u32,60
pub target_width: u32,35
pub target_height: u32,61
pub target_height: u32,36
pub fps: u32,62
pub fps: u32,63
#[serde(default)]64
pub framing: [CameraFraming; 2],37
}65
}38
66
39
impl Default for RenderSettings {67
impl Default for RenderSettings {@@ -42,6 +70,7 @@ impl Default for RenderSettings {42
target_width: 128,70
target_width: 128,43
target_height: 128,71
target_height: 128,44
fps: 12,72
fps: 12,73
framing: [CameraFraming::default(); 2],45
}74
}46
}75
}47
}76
}@@ -133,6 +162,12 @@ impl ProjectFile {133
162
134
pub fn validate_shape(&self) -> Result<(), ProjectError> {163
pub fn validate_shape(&self) -> Result<(), ProjectError> {135
let mut issues = Vec::new();164
let mut issues = Vec::new();165
for camera in self.render.framing {166
if let Err(error) = camera.validate() {167
issues.push(error);168
}169
}170
136
if self.format_version != FORMAT_VERSION {171
if self.format_version != FORMAT_VERSION {137
issues.push(format!(172
issues.push(format!(138
"dit project gebruikt format_version {}; deze build leest alleen versie {FORMAT_VERSION}",173
"dit project gebruikt format_version {}; deze build leest alleen versie {FORMAT_VERSION}",@@ -271,6 +306,7 @@ pub fn assemble(draft: ProjectDraft) -> Result<ProjectFile, ProjectError> {271
target_width: draft.target_width,306
target_width: draft.target_width,272
target_height: draft.target_height,307
target_height: draft.target_height,273
fps: draft.fps,308
fps: draft.fps,309
framing: [CameraFraming::default(); 2],274
},310
},275
};311
};276
document.validate_shape()?;312
document.validate_shape()?;@@ -293,12 +329,14 @@ pub fn parse_manifest(text: &str) -> Result<ProjectFile, ProjectError> {293
"format_version moet een geheel getal zijn".to_string(),329
"format_version moet een geheel getal zijn".to_string(),294
));330
));295
};331
};296
if version != u64::from(FORMAT_VERSION) {332
if version != 1 && version != u64::from(FORMAT_VERSION) {297
return Err(ProjectError::UnsupportedVersion {333
return Err(ProjectError::UnsupportedVersion {298
found: version,334
found: version,299
supported: FORMAT_VERSION,335
supported: FORMAT_VERSION,300
});336
});301
}337
}338
let mut value = value;339
value["format_version"] = serde_json::json!(FORMAT_VERSION);302
serde_json::from_value(value).map_err(|err| {340
serde_json::from_value(value).map_err(|err| {303
let detail = err.to_string();341
let detail = err.to_string();304
if detail.contains("unknown field") {342
if detail.contains("unknown field") {@@ -772,16 +810,16 @@ mod tests {772
810
773
#[test]811
#[test]774
fn newer_version_and_unknown_field_are_recoverable_parse_errors() {812
fn newer_version_and_unknown_field_are_recoverable_parse_errors() {775
let newer = r#"{"format_version":2,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;813
let newer = r#"{"format_version":3,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;776
let error = parse_manifest(newer).unwrap_err();814
let error = parse_manifest(newer).unwrap_err();777
assert!(matches!(815
assert!(matches!(778
error,816
error,779
ProjectError::UnsupportedVersion {817
ProjectError::UnsupportedVersion {780
found: 2,818
found: 3,781
supported: 1819
supported: 2782
}820
}783
));821
));784
assert!(error.to_string().contains("format_version 2"));822
assert!(error.to_string().contains("format_version 3"));785
let unknown = r#"{"format_version":1,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;823
let unknown = r#"{"format_version":1,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;786
let error = parse_manifest(unknown).unwrap_err();824
let error = parse_manifest(unknown).unwrap_err();787
assert!(matches!(error, ProjectError::UnknownField(_)));825
assert!(matches!(error, ProjectError::UnknownField(_)));@@ -840,6 +878,7 @@ mod tests {840
target_width: 4096,878
target_width: 4096,841
target_height: 4096,879
target_height: 4096,842
fps: 12,880
fps: 12,881
framing: [CameraFraming::default(); 2],843
};882
};844
assert!(document883
assert!(document845
.validate_shape()884
.validate_shape()@@ -889,3 +928,69 @@ mod tests {889
let _ = fs::remove_dir_all(&outside);928
let _ = fs::remove_dir_all(&outside);890
}929
}891
}930
}931
932
#[cfg(test)]933
mod framing_tests {934
use super::*;935
#[test]936
fn per_sex_fixed_framing_roundtrip_and_v1_migration() {937
let mut p = assemble(ProjectDraft {938
id: "fixed-framing".into(),939
name: "Frames".into(),940
male_model: String::new(),941
female_model: String::new(),942
equipment_presets: Vec::new(),943
output_directory: "renders".into(),944
target_width: 64,945
target_height: 96,946
fps: 12,947
})948
.unwrap();949
p.render.framing = [950
CameraFraming {951
height_milli: 3210,952
center_y_milli: 1100,953
},954
CameraFraming {955
height_milli: 2890,956
center_y_milli: 987,957
},958
];959
let root = std::env::temp_dir().join(format!(960
"paperdoll-framing-{}-{}",961
std::process::id(),962
std::time::SystemTime::now()963
.duration_since(std::time::UNIX_EPOCH)964
.unwrap()965
.as_nanos()966
));967
let mut opened = create_project(&root, "Fixed framing").unwrap();968
opened.document.render.framing = p.render.framing;969
save_project(&opened).unwrap();970
assert_eq!(971
open_project(&opened.manifest_path)972
.unwrap()973
.document974
.render975
.framing,976
p.render.framing977
);978
std::fs::remove_dir_all(root).unwrap();979
let mut value = serde_json::to_value(&p).unwrap();980
let text = serde_json::to_string(&value).unwrap();981
assert_eq!(982
parse_manifest(&text).unwrap().render.framing,983
p.render.framing984
);985
value["format_version"] = serde_json::json!(1);986
value["render"].as_object_mut().unwrap().remove("framing");987
let migrated = parse_manifest(&value.to_string()).unwrap();988
assert_eq!(migrated.format_version, 2);989
assert_eq!(migrated.render.framing, [CameraFraming::default(); 2]);990
value["render"]["framing"] = serde_json::json!([{"height_milli":2600,"center_y_milli":-2147483648},{"height_milli":2600,"center_y_milli":1000}]);991
assert!(parse_manifest(&value.to_string())992
.unwrap()993
.validate_shape()994
.is_err());995
}996
}@@ -1,28 +1,17 @@
1
//! Render queue for one body still.
2
//!
3
//! The worker walks the design order and emits an event after every pass.
4
//! The full production order is recorded, but this still renderer executes
5
//! and counts only its two real color passes. The GUI may coalesce events.
1
//! Actual production pass order and GUI state. The bounded worker/mailbox and
2
//! GPU service are implemented in `production`; previews retain native size.
6
3
7
use std::path::{Path, PathBuf};
8
use std::sync::mpsc::{self, Receiver};
9
use std::thread::{self, JoinHandle};
10
11
use crate::preview::{
12
paperdoll_yaws_deg, PreviewCharacter, PreviewControls, PAPERDOLL_PITCHES_DEG,
13
};
14
use crate::still::raster_body;
4
use crate::preview::{paperdoll_yaws_deg, PAPERDOLL_PITCHES_DEG};
5
pub use crate::production::{spawn, RenderJob, RenderThread};
15
6
16
7
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17
8
pub enum PassKind {
18
9
HighresImage,
19
10
DetailMap,
20
11
TargetResolution,
21
Diffuse,
22
12
Depth,
23
13
Normal,
24
Shiny,
25
RimHighlight,
14
Specular,
26
15
}
27
16
28
17
impl PassKind {
@@ -31,28 +20,24 @@ impl PassKind {
31
20
Self::HighresImage => "highres",
32
21
Self::DetailMap => "detail",
33
22
Self::TargetResolution => "doelresolutie",
34
Self::Diffuse => "diffuse",
35
23
Self::Depth => "depth",
36
24
Self::Normal => "normal",
37
Self::Shiny => "shiny",
38
Self::RimHighlight => "rim",
25
Self::Specular => "specular",
39
26
}
40
27
}
41
28
42
29
pub fn produces_image(self) -> bool {
43
matches!(self, Self::HighresImage | Self::TargetResolution)
30
true
44
31
}
45
32
}
46
33
47
pub const PASS_ORDER: [PassKind; 8] = [
34
pub const PASS_ORDER: [PassKind; 6] = [
48
35
PassKind::HighresImage,
49
36
PassKind::DetailMap,
50
37
PassKind::TargetResolution,
51
PassKind::Diffuse,
52
38
PassKind::Depth,
53
39
PassKind::Normal,
54
PassKind::Shiny,
55
PassKind::RimHighlight,
40
PassKind::Specular,
56
41
];
57
42
58
43
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -96,6 +81,7 @@ pub enum RenderEvent {
96
81
image: Option<ImageBuf>,
97
82
},
98
83
Failed(String),
84
Cancelled,
99
85
Finished {
100
86
saved: Option<String>,
101
87
},
@@ -105,6 +91,8 @@ pub enum RenderEvent {
105
91
pub struct RenderUiState {
106
92
pub progress: Progress,
107
93
pub preview: Option<ImageBuf>,
94
pub preview_label: String,
95
pub preview_revision: u64,
108
96
pub saved: Option<String>,
109
97
pub error: Option<String>,
110
98
pub running: bool,
@@ -119,6 +107,8 @@ impl RenderUiState {
119
107
label: String::new(),
120
108
},
121
109
preview: None,
110
preview_label: String::new(),
111
preview_revision: 0,
122
112
saved: None,
123
113
error: None,
124
114
running: false,
@@ -151,10 +141,16 @@ impl RenderUiState {
151
141
label,
152
142
};
153
143
if let Some(image) = image {
144
self.preview_label = self.progress.label.clone();
145
self.preview_revision = self.preview_revision.wrapping_add(1);
154
146
self.preview = Some(image);
155
147
}
156
148
self.running = true;
157
149
}
150
RenderEvent::Cancelled => {
151
self.running = false;
152
self.progress.label = "Geannuleerd; gedeeltelijke uitvoer behouden".into();
153
}
158
154
RenderEvent::Failed(message) => {
159
155
self.error = Some(message);
160
156
self.running = false;
@@ -197,16 +193,6 @@ pub fn pass_sequence(views: &[ViewAngle]) -> Vec<PassStep> {
197
193
steps
198
194
}
199
195
200
pub fn highres_extent(target_width: u32, target_height: u32) -> (u32, u32) {
201
let width = target_width.max(1);
202
let height = target_height.max(1);
203
let mut scale = 8u32;
204
while scale > 1 && (width.saturating_mul(scale) > 1024 || height.saturating_mul(scale) > 1024) {
205
scale -= 1;
206
}
207
(width * scale, height * scale)
208
}
209
210
196
/// Nearest-neighbor stretch. Every destination pixel is filled, so the
211
197
/// preview box has no empty border.
212
198
pub fn nearest_stretch(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
@@ -249,176 +235,9 @@ pub fn fit_preview(image: [u32; 2], bounds: [f32; 2]) -> PreviewFit {
249
235
}
250
236
}
251
237
252
pub struct RenderJob {
253
pub character: PreviewCharacter,
254
pub controls: PreviewControls,
255
pub target_width: u32,
256
pub target_height: u32,
257
pub preview_width: u32,
258
pub preview_height: u32,
259
pub output: PathBuf,
260
}
261
262
impl RenderJob {
263
pub fn steps(&self) -> Vec<PassStep> {
264
pass_sequence(&[ViewAngle {
265
pitch_deg: self.controls.pitch_deg,
266
yaw_deg: self.controls.yaw_deg,
267
}])
268
}
269
}
270
271
pub fn execute(job: &RenderJob, emit: &mut dyn FnMut(RenderEvent)) {
272
if job.target_width == 0
273
|| job.target_height == 0
274
|| job.target_width > crate::project::MAX_DIMENSION
275
|| job.target_height > crate::project::MAX_DIMENSION
276
{
277
emit(RenderEvent::Failed(
278
"ongeldige afmetingen voor de render".into(),
279
));
280
return;
281
}
282
// Count only implemented passes. Never report a skipped map as completed.
283
let steps: Vec<_> = job
284
.steps()
285
.into_iter()
286
.filter(|step| step.kind.produces_image())
287
.collect();
288
let total = steps.len() as u32;
289
let mut highres: Option<ImageBuf> = None;
290
let mut saved = None;
291
for (index, step) in steps.iter().enumerate() {
292
let index = index as u32;
293
let label = format!(
294
"pitch {:.0} yaw {:.0} · {}",
295
step.pitch_deg,
296
step.yaw_deg,
297
step.kind.label()
298
);
299
emit(RenderEvent::Step {
300
index,
301
total,
302
label: label.clone(),
303
});
304
if !step.kind.produces_image() {
305
emit(RenderEvent::Pass {
306
index,
307
total,
308
label,
309
image: None,
310
});
311
continue;
312
}
313
let image = match step.kind {
314
PassKind::HighresImage => {
315
let mut controls = job.controls.clone();
316
controls.pitch_deg = step.pitch_deg;
317
controls.yaw_deg = step.yaw_deg;
318
let (width, height) = highres_extent(job.target_width, job.target_height);
319
match raster_body(&job.character, &controls, width, height) {
320
Ok(rgba) => {
321
let image = ImageBuf {
322
width,
323
height,
324
rgba,
325
};
326
highres = Some(image.clone());
327
Some(image)
328
}
329
Err(message) => {
330
emit(RenderEvent::Failed(message));
331
return;
332
}
333
}
334
}
335
PassKind::TargetResolution => {
336
let Some(source) = highres.as_ref() else {
337
emit(RenderEvent::Failed(
338
"de doelresolutie volgt op het highres-beeld".to_string(),
339
));
340
return;
341
};
342
let rgba = nearest_stretch(
343
&source.rgba,
344
source.width,
345
source.height,
346
job.target_width.max(1),
347
job.target_height.max(1),
348
);
349
let target = ImageBuf {
350
width: job.target_width.max(1),
351
height: job.target_height.max(1),
352
rgba,
353
};
354
match write_png(&job.output, &target) {
355
Ok(()) => saved = Some(job.output.display().to_string()),
356
Err(message) => {
357
emit(RenderEvent::Failed(message));
358
return;
359
}
360
}
361
// Keep native dimensions; only the GUI scales for presentation.
362
Some(target)
363
}
364
_ => None,
365
};
366
emit(RenderEvent::Pass {
367
index,
368
total,
369
label,
370
image,
371
});
372
}
373
emit(RenderEvent::Finished { saved });
374
}
375
376
pub struct RenderThread {
377
pub rx: Receiver<RenderEvent>,
378
pub handle: JoinHandle<()>,
379
}
380
381
pub fn spawn(job: RenderJob) -> RenderThread {
382
let (tx, rx) = mpsc::channel();
383
let handle = thread::spawn(move || {
384
execute(&job, &mut |event| {
385
let _ = tx.send(event);
386
});
387
});
388
RenderThread { rx, handle }
389
}
390
391
fn write_png(path: &Path, image: &ImageBuf) -> Result<(), String> {
392
if let Some(parent) = path.parent() {
393
std::fs::create_dir_all(parent).map_err(|err| err.to_string())?;
394
}
395
let stamp = std::time::SystemTime::now()
396
.duration_since(std::time::UNIX_EPOCH)
397
.unwrap_or_default()
398
.as_nanos();
399
let temp = path.with_extension(format!("png.part-{}-{stamp}", std::process::id()));
400
let file = std::fs::OpenOptions::new()
401
.create_new(true)
402
.write(true)
403
.open(&temp)
404
.map_err(|err| err.to_string())?;
405
let mut encoder = png::Encoder::new(file, image.width, image.height);
406
encoder.set_color(png::ColorType::Rgba);
407
encoder.set_depth(png::BitDepth::Eight);
408
let mut writer = encoder.write_header().map_err(|err| err.to_string())?;
409
writer
410
.write_image_data(&image.rgba)
411
.map_err(|err| err.to_string())?;
412
drop(writer);
413
std::fs::rename(&temp, path).map_err(|err| err.to_string())?;
414
Ok(())
415
}
416
417
238
#[cfg(test)]
418
239
mod tests {
419
240
use super::*;
420
use crate::preview::load_gltf_file;
421
use std::time::{SystemTime, UNIX_EPOCH};
422
241
423
242
#[test]
424
243
fn full_view_order_is_pitch_then_yaw_then_passes() {
@@ -429,11 +248,11 @@ mod tests {
429
248
assert_eq!(views[11].yaw_deg, 330.0);
430
249
assert_eq!(views[12].pitch_deg, 0.0);
431
250
let steps = pass_sequence(&views);
432
assert_eq!(steps.len(), 36 * 8);
433
let first: Vec<_> = steps.iter().take(8).map(|step| step.kind).collect();
251
assert_eq!(steps.len(), 36 * 6);
252
let first: Vec<_> = steps.iter().take(6).map(|step| step.kind).collect();
434
253
assert_eq!(first, PASS_ORDER.to_vec());
435
assert_eq!(steps[8].yaw_deg, 30.0);
436
assert_eq!(steps[8].kind, PassKind::HighresImage);
254
assert_eq!(steps[6].yaw_deg, 30.0);
255
assert_eq!(steps[6].kind, PassKind::HighresImage);
437
256
}
438
257
439
258
#[test]
@@ -480,64 +299,31 @@ mod tests {
480
299
}
481
300
482
301
#[test]
483
fn progress_follows_steps_and_skips_do_not_replace_the_picture() {
484
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
485
.join("tests/fixtures/bodies/test-body-male.glb");
486
let character = load_gltf_file(&path).unwrap();
487
let nanos = SystemTime::now()
488
.duration_since(UNIX_EPOCH)
489
.unwrap()
490
.as_nanos();
491
let output = std::env::temp_dir().join(format!("paperdoll-still-{nanos}.png"));
492
let job = RenderJob {
493
character,
494
controls: PreviewControls::default(),
495
target_width: 32,
496
target_height: 40,
497
preview_width: 64,
498
preview_height: 80,
499
output: output.clone(),
500
};
501
let mut events = Vec::new();
502
execute(&job, &mut |event| events.push(event));
302
fn pass_progress_cancellation_and_last_image_are_preserved() {
503
303
let mut ui = RenderUiState::idle();
504
for event in events {
505
ui.apply(event);
506
}
507
assert_eq!(ui.progress.completed, 2);
508
assert_eq!(ui.progress.total, 2);
509
assert!(ui.error.is_none());
510
let preview = ui.preview.expect("a real pass image");
511
assert_eq!((preview.width, preview.height), (32, 40));
512
let filled = preview.rgba.chunks_exact(4).filter(|p| p[3] == 255).count();
513
assert_eq!(filled, 32 * 40);
514
assert!(output.is_file());
515
let _ = std::fs::remove_file(&output);
516
}
517
518
#[test]
519
fn skipped_maps_have_no_image() {
520
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
521
.join("tests/fixtures/bodies/test-body-male.glb");
522
let job = RenderJob {
523
character: load_gltf_file(&path).unwrap(),
524
controls: PreviewControls::default(),
525
target_width: 16,
526
target_height: 16,
527
preview_width: 16,
528
preview_height: 16,
529
output: std::env::temp_dir().join("paperdoll-skip-check.png"),
530
};
531
let mut images = Vec::new();
532
execute(&job, &mut |event| {
533
if let RenderEvent::Pass { label, image, .. } = event {
534
images.push((label, image.is_some()));
535
}
304
ui.apply(RenderEvent::Pass {
305
index: 1,
306
total: 6,
307
label: "detail".into(),
308
image: Some(ImageBuf {
309
width: 1,
310
height: 1,
311
rgba: vec![1, 2, 3, 255],
312
}),
536
313
});
537
assert_eq!(images.len(), 2);
538
assert!(images.iter().all(|(_, has_image)| *has_image));
539
assert!(images[0].0.ends_with("highres"));
540
assert!(images[1].0.ends_with("doelresolutie"));
541
let _ = std::fs::remove_file(&job.output);
314
ui.apply(RenderEvent::Step {
315
index: 2,
316
total: 6,
317
label: "target".into(),
318
});
319
assert_eq!(ui.preview.as_ref().unwrap().rgba, vec![1, 2, 3, 255]);
320
assert_eq!(ui.preview_revision, 1);
321
assert_eq!(ui.preview_label, "detail");
322
assert_eq!(ui.progress.label, "target");
323
ui.apply(RenderEvent::Cancelled);
324
assert!(!ui.running);
325
assert_eq!(ui.progress.completed, 2);
326
assert_eq!(ui.preview_revision, 1);
327
assert_eq!(ui.preview.as_ref().unwrap().rgba, vec![1, 2, 3, 255]);
542
328
}
543
329
}
@@ -1,28 +1,17 @@1
//! Render queue for one body still.1
//! Actual production pass order and GUI state. The bounded worker/mailbox and2
//!2
//! GPU service are implemented in `production`; previews retain native size.3
//! The worker walks the design order and emits an event after every pass.4
//! The full production order is recorded, but this still renderer executes5
//! and counts only its two real color passes. The GUI may coalesce events.6
3
7
use std::path::{Path, PathBuf};4
use crate::preview::{paperdoll_yaws_deg, PAPERDOLL_PITCHES_DEG};8
use std::sync::mpsc::{self, Receiver};5
pub use crate::production::{spawn, RenderJob, RenderThread};9
use std::thread::{self, JoinHandle};10
11
use crate::preview::{12
paperdoll_yaws_deg, PreviewCharacter, PreviewControls, PAPERDOLL_PITCHES_DEG,13
};14
use crate::still::raster_body;15
6
16
#[derive(Debug, Clone, Copy, PartialEq, Eq)]7
#[derive(Debug, Clone, Copy, PartialEq, Eq)]17
pub enum PassKind {8
pub enum PassKind {18
HighresImage,9
HighresImage,19
DetailMap,10
DetailMap,20
TargetResolution,11
TargetResolution,21
Diffuse,22
Depth,12
Depth,23
Normal,13
Normal,24
Shiny,14
Specular,25
RimHighlight,26
}15
}27
16
28
impl PassKind {17
impl PassKind {@@ -31,28 +20,24 @@ impl PassKind {31
Self::HighresImage => "highres",20
Self::HighresImage => "highres",32
Self::DetailMap => "detail",21
Self::DetailMap => "detail",33
Self::TargetResolution => "doelresolutie",22
Self::TargetResolution => "doelresolutie",34
Self::Diffuse => "diffuse",35
Self::Depth => "depth",23
Self::Depth => "depth",36
Self::Normal => "normal",24
Self::Normal => "normal",37
Self::Shiny => "shiny",25
Self::Specular => "specular",38
Self::RimHighlight => "rim",39
}26
}40
}27
}41
28
42
pub fn produces_image(self) -> bool {29
pub fn produces_image(self) -> bool {43
matches!(self, Self::HighresImage | Self::TargetResolution)30
true44
}31
}45
}32
}46
33
47
pub const PASS_ORDER: [PassKind; 8] = [34
pub const PASS_ORDER: [PassKind; 6] = [48
PassKind::HighresImage,35
PassKind::HighresImage,49
PassKind::DetailMap,36
PassKind::DetailMap,50
PassKind::TargetResolution,37
PassKind::TargetResolution,51
PassKind::Diffuse,52
PassKind::Depth,38
PassKind::Depth,53
PassKind::Normal,39
PassKind::Normal,54
PassKind::Shiny,40
PassKind::Specular,55
PassKind::RimHighlight,56
];41
];57
42
58
#[derive(Debug, Clone, Copy, PartialEq)]43
#[derive(Debug, Clone, Copy, PartialEq)]@@ -96,6 +81,7 @@ pub enum RenderEvent {96
image: Option<ImageBuf>,81
image: Option<ImageBuf>,97
},82
},98
Failed(String),83
Failed(String),84
Cancelled,99
Finished {85
Finished {100
saved: Option<String>,86
saved: Option<String>,101
},87
},@@ -105,6 +91,8 @@ pub enum RenderEvent {105
pub struct RenderUiState {91
pub struct RenderUiState {106
pub progress: Progress,92
pub progress: Progress,107
pub preview: Option<ImageBuf>,93
pub preview: Option<ImageBuf>,94
pub preview_label: String,95
pub preview_revision: u64,108
pub saved: Option<String>,96
pub saved: Option<String>,109
pub error: Option<String>,97
pub error: Option<String>,110
pub running: bool,98
pub running: bool,@@ -119,6 +107,8 @@ impl RenderUiState {119
label: String::new(),107
label: String::new(),120
},108
},121
preview: None,109
preview: None,110
preview_label: String::new(),111
preview_revision: 0,122
saved: None,112
saved: None,123
error: None,113
error: None,124
running: false,114
running: false,@@ -151,10 +141,16 @@ impl RenderUiState {151
label,141
label,152
};142
};153
if let Some(image) = image {143
if let Some(image) = image {144
self.preview_label = self.progress.label.clone();145
self.preview_revision = self.preview_revision.wrapping_add(1);154
self.preview = Some(image);146
self.preview = Some(image);155
}147
}156
self.running = true;148
self.running = true;157
}149
}150
RenderEvent::Cancelled => {151
self.running = false;152
self.progress.label = "Geannuleerd; gedeeltelijke uitvoer behouden".into();153
}158
RenderEvent::Failed(message) => {154
RenderEvent::Failed(message) => {159
self.error = Some(message);155
self.error = Some(message);160
self.running = false;156
self.running = false;@@ -197,16 +193,6 @@ pub fn pass_sequence(views: &[ViewAngle]) -> Vec<PassStep> {197
steps193
steps198
}194
}199
195
200
pub fn highres_extent(target_width: u32, target_height: u32) -> (u32, u32) {201
let width = target_width.max(1);202
let height = target_height.max(1);203
let mut scale = 8u32;204
while scale > 1 && (width.saturating_mul(scale) > 1024 || height.saturating_mul(scale) > 1024) {205
scale -= 1;206
}207
(width * scale, height * scale)208
}209
210
/// Nearest-neighbor stretch. Every destination pixel is filled, so the196
/// Nearest-neighbor stretch. Every destination pixel is filled, so the211
/// preview box has no empty border.197
/// preview box has no empty border.212
pub fn nearest_stretch(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {198
pub fn nearest_stretch(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {@@ -249,176 +235,9 @@ pub fn fit_preview(image: [u32; 2], bounds: [f32; 2]) -> PreviewFit {249
}235
}250
}236
}251
237
252
pub struct RenderJob {253
pub character: PreviewCharacter,254
pub controls: PreviewControls,255
pub target_width: u32,256
pub target_height: u32,257
pub preview_width: u32,258
pub preview_height: u32,259
pub output: PathBuf,260
}261
262
impl RenderJob {263
pub fn steps(&self) -> Vec<PassStep> {264
pass_sequence(&[ViewAngle {265
pitch_deg: self.controls.pitch_deg,266
yaw_deg: self.controls.yaw_deg,267
}])268
}269
}270
271
pub fn execute(job: &RenderJob, emit: &mut dyn FnMut(RenderEvent)) {272
if job.target_width == 0273
|| job.target_height == 0274
|| job.target_width > crate::project::MAX_DIMENSION275
|| job.target_height > crate::project::MAX_DIMENSION276
{277
emit(RenderEvent::Failed(278
"ongeldige afmetingen voor de render".into(),279
));280
return;281
}282
// Count only implemented passes. Never report a skipped map as completed.283
let steps: Vec<_> = job284
.steps()285
.into_iter()286
.filter(|step| step.kind.produces_image())287
.collect();288
let total = steps.len() as u32;289
let mut highres: Option<ImageBuf> = None;290
let mut saved = None;291
for (index, step) in steps.iter().enumerate() {292
let index = index as u32;293
let label = format!(294
"pitch {:.0} yaw {:.0} · {}",295
step.pitch_deg,296
step.yaw_deg,297
step.kind.label()298
);299
emit(RenderEvent::Step {300
index,301
total,302
label: label.clone(),303
});304
if !step.kind.produces_image() {305
emit(RenderEvent::Pass {306
index,307
total,308
label,309
image: None,310
});311
continue;312
}313
let image = match step.kind {314
PassKind::HighresImage => {315
let mut controls = job.controls.clone();316
controls.pitch_deg = step.pitch_deg;317
controls.yaw_deg = step.yaw_deg;318
let (width, height) = highres_extent(job.target_width, job.target_height);319
match raster_body(&job.character, &controls, width, height) {320
Ok(rgba) => {321
let image = ImageBuf {322
width,323
height,324
rgba,325
};326
highres = Some(image.clone());327
Some(image)328
}329
Err(message) => {330
emit(RenderEvent::Failed(message));331
return;332
}333
}334
}335
PassKind::TargetResolution => {336
let Some(source) = highres.as_ref() else {337
emit(RenderEvent::Failed(338
"de doelresolutie volgt op het highres-beeld".to_string(),339
));340
return;341
};342
let rgba = nearest_stretch(343
&source.rgba,344
source.width,345
source.height,346
job.target_width.max(1),347
job.target_height.max(1),348
);349
let target = ImageBuf {350
width: job.target_width.max(1),351
height: job.target_height.max(1),352
rgba,353
};354
match write_png(&job.output, &target) {355
Ok(()) => saved = Some(job.output.display().to_string()),356
Err(message) => {357
emit(RenderEvent::Failed(message));358
return;359
}360
}361
// Keep native dimensions; only the GUI scales for presentation.362
Some(target)363
}364
_ => None,365
};366
emit(RenderEvent::Pass {367
index,368
total,369
label,370
image,371
});372
}373
emit(RenderEvent::Finished { saved });374
}375
376
pub struct RenderThread {377
pub rx: Receiver<RenderEvent>,378
pub handle: JoinHandle<()>,379
}380
381
pub fn spawn(job: RenderJob) -> RenderThread {382
let (tx, rx) = mpsc::channel();383
let handle = thread::spawn(move || {384
execute(&job, &mut |event| {385
let _ = tx.send(event);386
});387
});388
RenderThread { rx, handle }389
}390
391
fn write_png(path: &Path, image: &ImageBuf) -> Result<(), String> {392
if let Some(parent) = path.parent() {393
std::fs::create_dir_all(parent).map_err(|err| err.to_string())?;394
}395
let stamp = std::time::SystemTime::now()396
.duration_since(std::time::UNIX_EPOCH)397
.unwrap_or_default()398
.as_nanos();399
let temp = path.with_extension(format!("png.part-{}-{stamp}", std::process::id()));400
let file = std::fs::OpenOptions::new()401
.create_new(true)402
.write(true)403
.open(&temp)404
.map_err(|err| err.to_string())?;405
let mut encoder = png::Encoder::new(file, image.width, image.height);406
encoder.set_color(png::ColorType::Rgba);407
encoder.set_depth(png::BitDepth::Eight);408
let mut writer = encoder.write_header().map_err(|err| err.to_string())?;409
writer410
.write_image_data(&image.rgba)411
.map_err(|err| err.to_string())?;412
drop(writer);413
std::fs::rename(&temp, path).map_err(|err| err.to_string())?;414
Ok(())415
}416
417
#[cfg(test)]238
#[cfg(test)]418
mod tests {239
mod tests {419
use super::*;240
use super::*;420
use crate::preview::load_gltf_file;421
use std::time::{SystemTime, UNIX_EPOCH};422
241
423
#[test]242
#[test]424
fn full_view_order_is_pitch_then_yaw_then_passes() {243
fn full_view_order_is_pitch_then_yaw_then_passes() {@@ -429,11 +248,11 @@ mod tests {429
assert_eq!(views[11].yaw_deg, 330.0);248
assert_eq!(views[11].yaw_deg, 330.0);430
assert_eq!(views[12].pitch_deg, 0.0);249
assert_eq!(views[12].pitch_deg, 0.0);431
let steps = pass_sequence(&views);250
let steps = pass_sequence(&views);432
assert_eq!(steps.len(), 36 * 8);251
assert_eq!(steps.len(), 36 * 6);433
let first: Vec<_> = steps.iter().take(8).map(|step| step.kind).collect();252
let first: Vec<_> = steps.iter().take(6).map(|step| step.kind).collect();434
assert_eq!(first, PASS_ORDER.to_vec());253
assert_eq!(first, PASS_ORDER.to_vec());435
assert_eq!(steps[8].yaw_deg, 30.0);254
assert_eq!(steps[6].yaw_deg, 30.0);436
assert_eq!(steps[8].kind, PassKind::HighresImage);255
assert_eq!(steps[6].kind, PassKind::HighresImage);437
}256
}438
257
439
#[test]258
#[test]@@ -480,64 +299,31 @@ mod tests {480
}299
}481
300
482
#[test]301
#[test]483
fn progress_follows_steps_and_skips_do_not_replace_the_picture() {302
fn pass_progress_cancellation_and_last_image_are_preserved() {484
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))485
.join("tests/fixtures/bodies/test-body-male.glb");486
let character = load_gltf_file(&path).unwrap();487
let nanos = SystemTime::now()488
.duration_since(UNIX_EPOCH)489
.unwrap()490
.as_nanos();491
let output = std::env::temp_dir().join(format!("paperdoll-still-{nanos}.png"));492
let job = RenderJob {493
character,494
controls: PreviewControls::default(),495
target_width: 32,496
target_height: 40,497
preview_width: 64,498
preview_height: 80,499
output: output.clone(),500
};501
let mut events = Vec::new();502
execute(&job, &mut |event| events.push(event));503
let mut ui = RenderUiState::idle();303
let mut ui = RenderUiState::idle();504
for event in events {304
ui.apply(RenderEvent::Pass {505
ui.apply(event);305
index: 1,506
}306
total: 6,507
assert_eq!(ui.progress.completed, 2);307
label: "detail".into(),508
assert_eq!(ui.progress.total, 2);308
image: Some(ImageBuf {509
assert!(ui.error.is_none());309
width: 1,510
let preview = ui.preview.expect("a real pass image");310
height: 1,511
assert_eq!((preview.width, preview.height), (32, 40));311
rgba: vec![1, 2, 3, 255],512
let filled = preview.rgba.chunks_exact(4).filter(|p| p[3] == 255).count();312
}),513
assert_eq!(filled, 32 * 40);514
assert!(output.is_file());515
let _ = std::fs::remove_file(&output);516
}517
518
#[test]519
fn skipped_maps_have_no_image() {520
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))521
.join("tests/fixtures/bodies/test-body-male.glb");522
let job = RenderJob {523
character: load_gltf_file(&path).unwrap(),524
controls: PreviewControls::default(),525
target_width: 16,526
target_height: 16,527
preview_width: 16,528
preview_height: 16,529
output: std::env::temp_dir().join("paperdoll-skip-check.png"),530
};531
let mut images = Vec::new();532
execute(&job, &mut |event| {533
if let RenderEvent::Pass { label, image, .. } = event {534
images.push((label, image.is_some()));535
}536
});313
});537
assert_eq!(images.len(), 2);314
ui.apply(RenderEvent::Step {538
assert!(images.iter().all(|(_, has_image)| *has_image));315
index: 2,539
assert!(images[0].0.ends_with("highres"));316
total: 6,540
assert!(images[1].0.ends_with("doelresolutie"));317
label: "target".into(),541
let _ = std::fs::remove_file(&job.output);318
});319
assert_eq!(ui.preview.as_ref().unwrap().rgba, vec![1, 2, 3, 255]);320
assert_eq!(ui.preview_revision, 1);321
assert_eq!(ui.preview_label, "detail");322
assert_eq!(ui.progress.label, "target");323
ui.apply(RenderEvent::Cancelled);324
assert!(!ui.running);325
assert_eq!(ui.progress.completed, 2);326
assert_eq!(ui.preview_revision, 1);327
assert_eq!(ui.preview.as_ref().unwrap().rgba, vec![1, 2, 3, 255]);542
}328
}543
}329
}