BlueGit

golden-age paperdoll-renderer

Merge feature/production-render: all-animation 36-view target maps

timfa Grok Bot · pushed by timfa

Authored 2026-11-06 · 7 days ago

Hash
c70e7a0ec2fc6887741f85e24266ec3550c3fba1
Pipeline
Passed
12 files changed +2762 −404
README.md +18 −4
@@ -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 ![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
@@ -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 Renderer
1# Paperdoll Renderer
2
2
3Desktopeditor voor `.ppd`-projecten, modelimport en een geanimeerde 3D-preview.
3Desktopeditor voor `.ppd`-projecten, bone-gebonden equipment en geanimeerde 3D-preview, met productie-export naar paperdoll-maps.
4
4
5## Starten
5## Starten
6
6
@@ -21,10 +21,10 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
21
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![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
30![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
@@ -53,6 +53,20 @@ cargo run --example equipment_fixture --locked
53
53
54Bij 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.
54Bij 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
58De 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
60Per 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
63renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-30_y330_diffuse.png
64```
65
66Ieder 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
68Highres 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 met
70## Openen met
57
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 --check
68cargo check --workspace --all-targets --locked
82cargo check --workspace --all-targets --locked
69cargo clippy --workspace --all-targets --locked -- -D warnings
83cargo clippy --workspace --all-targets --locked -- -D warnings
70cargo test --workspace --locked
84cargo 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
72xvfb-run -a cargo run --example preview_depth_probe --locked
86xvfb-run -a cargo run --example preview_depth_probe --locked
73```
87```
74
88
design.md +38 −27
@@ -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 — design
1# Paperdoll Renderer — design
2
2
3**Status: design recorded with a foundation shell only.**
3**Status: design recorded with a foundation shell only.**
4Prepared 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.
4Prepared 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
6Anonymous 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`.
6Anonymous 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
68This 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.
68This 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
70FBX 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).
70FBX 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/`, `presets
94
94
95Validate 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.
95Validate 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
99Implemented 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
101Paths 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 sampling
103## 6. Camera, axes, alignment and animation sampling
102
104
103Required 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.
105Required 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
105Use 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
111The 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.
113Highres 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 metadata
115## 7. Export naming and metadata
114
116
115Required schema: `<geslacht>-<settype>-<animatienaam>-<pitch>-<yaw>-<maptype>-f<framenummer>.png`.
117The current hierarchical layout supersedes the earlier flat filename proposal (the original brief is retained verbatim in the historical appendix):
118
119```text
120renders/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
119The 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 algorithm
129## 8. Map contracts and rendering algorithm
122
130
123Diffuse 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
125Depth **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
127Normals **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.
139Per pose/view, the actual completed-preview order is:
1401. **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.
1412. **Detail:** color/alpha/depth/normal neighbor discontinuities, bounded weights. Publish actual edge map immediately, no file saved.
1423. **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.
1434. **Depth:** write target 16-bit gray+alpha, publish its gray8 visualization (the saved depth keeps 16-bit precision).
1445. **Normal:** write/publish target normal.
1456. **Specular:** write/publish target scalar map, including body zero.
128
146
129Equipment 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.
147Every 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
131Pipeline per shared scene sample/view:
149Current 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.
1321. Evaluate skeleton and bone world transforms at the exact sample time.
1332. Render high-resolution unlit color/coverage and geometry buffers at 8x target dimensions. Depth/normals originate from the actual same surfaces, not image luminance.
1343. 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.
1354. 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.
1365. 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.
1376. 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.
1387. Encode all target-resolution PNGs atomically and record checksums/metadata. Test deterministic CPU stages independently from GPU tolerance.
139
150
140Equipment 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.
151Equipment 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## 11. Development workflow, quality and packaging
187## 11. Development workflow, quality and packaging
177
188
178Each 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.
189Each 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
180After 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.
191After 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
182The 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`.
193The 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
184Live 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.
195Live 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
186CI 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.
197CI 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- 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
207Current 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.
218Current 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 gates
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
292Each 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.
303Each 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
examples/preview_depth_probe.rs +1 −0
@@ -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))
examples/production_fixture.rs added +128 −0
@@ -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]
3use eframe::App;
4use paperdoll_renderer::{
5 editor::EditorApp,
6 equipment, preview,
7 session::{adopt_file, Session, Sex},
8};
9use std::path::PathBuf;
10struct Smoke {
11 editor: EditorApp,
12 root: PathBuf,
13 last: u32,
14 frames: u64,
15 done: bool,
16}
17impl 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}
44fn 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}
src/editor.rs +242 −90
@@ -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
3use std::path::PathBuf;
3use std::path::PathBuf;
4use std::sync::mpsc::Receiver;
5
4
6use eframe::egui;
5use 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.running
354 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.session
362 .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 = root
413 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 = 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 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 = worker
405 let event = match rx.try_recv() {
452 .handle
406 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::Cancelled
415 };
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 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
433impl Default for EditorApp {
549impl Default for EditorApp {
@@ -438,11 +554,16 @@ impl Default for EditorApp {
438
554
439impl eframe::App for EditorApp {
555impl 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 ui
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 .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 f32
782 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.label
665 )));
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.viewport
793 .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_label
690 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_revision
693 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 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 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.viewport
1111 .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());
src/foundation.rs +17 −9
@@ -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 the
3//! 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::GltfGlb
64 | Capability::GltfGlb
65 | Capability::Fbx
65 | Capability::Fbx
66 | Capability::SkeletalAnimation
66 | Capability::SkeletalAnimation
67 | Capability::AttachmentEditor => Availability::Present,
67 | Capability::AttachmentEditor
68 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
112pub fn dutch_summary() -> &'static str {
113pub 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::Present
239 Availability::Present
@@ -251,7 +252,12 @@ mod tests {
251 availability(Capability::AttachmentEditor),
252 availability(Capability::AttachmentEditor),
252 Availability::Present
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 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}"
src/lib.rs +2 −1
@@ -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
6pub mod dialogs;
6pub mod dialogs;
7pub mod editor;
7pub mod editor;
@@ -12,6 +12,7 @@ pub mod icon;
12pub mod import;
12pub mod import;
13pub mod preview;
13pub mod preview;
14pub mod preview_gl;
14pub mod preview_gl;
15pub mod production;
15pub mod project;
16pub mod project;
16pub mod render_job;
17pub mod render_job;
17pub mod session;
18pub mod session;
src/preview.rs +93 −1
@@ -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)]
103pub 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)]
98pub struct SceneNode {
113pub struct SceneNode {
99 pub name: String,
114 pub name: String,
@@ -126,7 +141,7 @@ pub struct AnimChannel {
126}
141}
127
142
128pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
143pub 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]> = reader
201 let prim_positions: Vec<[f32; 3]> = reader
180 .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]> = reader
206 let prim_normals: Vec<[f32; 3]> = reader
184 .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 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 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 = 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 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}
src/preview_gl.rs +57 −3
@@ -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 !character
73 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 rect
132 };
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 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 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_proj
190 };
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());
src/production.rs added +2006 −0
@@ -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 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.
4use crate::{
5 equipment,
6 preview::{self, Mat4, PreviewCharacter, PreviewControls},
7 project::CameraFraming,
8 render_job::{ImageBuf, RenderEvent},
9};
10use glow::HasContext;
11use std::{
12 path::{Path, PathBuf},
13 sync::{
14 atomic::{AtomicBool, Ordering},
15 mpsc, Arc, Mutex,
16 },
17 thread,
18};
19
20pub const SCALE: u32 = 8;
21pub const MAX_HIGHRES: u32 = 2048;
22pub const PASSES: [&str; 6] = [
23 "highres",
24 "detail",
25 "target-res / diffuse",
26 "depth",
27 "normal",
28 "specular",
29];
30pub const NEAR: f32 = 0.05;
31pub const FAR: f32 = 40.;
32#[derive(Clone)]
33pub 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)]
47pub 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)]
55pub 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)]
64pub 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)]
72pub 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)]
83pub struct ClipPlan {
84 pub source_name: String,
85 pub token: String,
86 pub frames: u32,
87}
88#[derive(Debug, Clone, PartialEq)]
89pub struct Plan {
90 pub clips: Vec<ClipPlan>,
91 pub images: u32,
92 pub passes: u32,
93 pub files: u32,
94}
95pub 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)]
151pub struct Sample {
152 pub clip: usize,
153 pub frame: u32,
154 pub pitch: i32,
155 pub yaw: u32,
156}
157pub 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
172pub 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}
189pub 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}
214pub 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}
226fn 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}
229fn 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}
237fn 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}
259pub 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
377impl 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)]
427pub 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}
437impl 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}
804fn 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}
811const VERTEX: &str = r#"#version 330
812layout(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;
813uniform mat4 u_view;uniform mat4 u_projection;out vec3 v_normal;out vec3 v_view_position;out vec2 v_uv;out vec4 v_color;
814void 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"#;
816const FRAGMENT: &str = r#"#version 330
817in 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
818uniform float u_specular;
819uniform sampler2D u_albedo;uniform sampler2D u_shiny;uniform bool u_albedo_enabled;uniform bool u_shiny_enabled;
820layout(location=0)out vec4 diffuse;layout(location=1)out vec4 normal;layout(location=2)out vec4 specular;
821vec3 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));}
822void 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
825fn image(w: u32, h: u32, rgba: Vec<u8>) -> ImageBuf {
826 ImageBuf {
827 width: w,
828 height: h,
829 rgba,
830 }
831}
832fn 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
846fn 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}
854impl 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}
877pub 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}
914pub 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}
921pub 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
1013fn 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}
1047fn 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}
1072pub 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}
1100pub 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}
1125fn 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
1133pub struct GpuRequest {
1134 pub scene: Scene,
1135 reply: mpsc::SyncSender<Result<Buffers, String>>,
1136}
1137impl 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)]
1149struct Pending {
1150 pass: Option<RenderEvent>,
1151 progress: Option<RenderEvent>,
1152 terminal: Option<RenderEvent>,
1153}
1154#[derive(Clone, Default)]
1155pub struct Mailbox(Arc<Mutex<Pending>>);
1156impl 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}
1179pub struct RenderThread {
1180 pub mailbox: Mailbox,
1181 pub gpu: mpsc::Receiver<GpuRequest>,
1182 pub cancel: Arc<AtomicBool>,
1183 pub handle: Option<thread::JoinHandle<()>>,
1184}
1185impl Drop for RenderThread {
1186 fn drop(&mut self) {
1187 self.cancel.store(true, Ordering::Relaxed);
1188 }
1189}
1190pub 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}
1219fn 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.
1434pub 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)]
1558mod 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}
src/project.rs +112 −7
@@ -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 and
3//! Version 1 stores paths and render settings. It does not import models and
4//! it does not render paperdolls. Unknown JSON fields are rejected so a save
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
7use std::fs::{self, File};
7use std::fs::{self, File};
8use std::io::Write;
8use std::io::Write;
@@ -12,7 +12,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
12use serde::{Deserialize, Serialize};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
13use serde_json::Value;
14
14
15pub const FORMAT_VERSION: u32 = 1;
15pub const FORMAT_VERSION: u32 = 2;
16pub const MANIFEST_NAME: &str = "character.ppd";
16pub const MANIFEST_NAME: &str = "character.ppd";
17pub const MAX_PROJECT_PRESETS: usize = 64;
17pub const MAX_PROJECT_PRESETS: usize = 64;
18pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
18pub 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)]
34pub struct CameraFraming {
35 pub height_milli: u32,
36 pub center_y_milli: i32,
37}
38impl Default for CameraFraming {
39 fn default() -> Self {
40 Self {
41 height_milli: 2600,
42 center_y_milli: 1000,
43 }
44 }
45}
46impl 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#[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)]
33pub struct RenderSettings {
59pub 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
39impl Default for RenderSettings {
67impl 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: 1
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 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!(document
883 assert!(document
845 .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)]
933mod 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}
src/render_job.rs +48 −262
@@ -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 and
2//!
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 executes
5//! and counts only its two real color passes. The GUI may coalesce events.
6
3
7use std::path::{Path, PathBuf};
4use crate::preview::{paperdoll_yaws_deg, PAPERDOLL_PITCHES_DEG};
8use std::sync::mpsc::{self, Receiver};
5pub use crate::production::{spawn, RenderJob, RenderThread};
9use std::thread::{self, JoinHandle};
10
11use crate::preview::{
12 paperdoll_yaws_deg, PreviewCharacter, PreviewControls, PAPERDOLL_PITCHES_DEG,
13};
14use crate::still::raster_body;
15
6
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum PassKind {
8pub 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
28impl PassKind {
17impl 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 true
44 }
31 }
45}
32}
46
33
47pub const PASS_ORDER: [PassKind; 8] = [
34pub 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 {
105pub struct RenderUiState {
91pub 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 steps
193 steps
198}
194}
199
195
200pub 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 the
196/// Nearest-neighbor stretch. Every destination pixel is filled, so the
211/// preview box has no empty border.
197/// preview box has no empty border.
212pub fn nearest_stretch(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
198pub 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
252pub 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
262impl 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
271pub 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
376pub struct RenderThread {
377 pub rx: Receiver<RenderEvent>,
378 pub handle: JoinHandle<()>,
379}
380
381pub 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
391fn 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#[cfg(test)]
238#[cfg(test)]
418mod tests {
239mod 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}