Measure the 3D library and open the model review scorecard
This commit is contained in:
1 parent
2443343f2b
commit
2d67e14e52
8 files changed
+412
-12
No files matched your search
@@ -4,6 +4,15 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
|
- **3D review baseline (2026-11-03):** `docs/3d-model-review.md` records the first skill-guided
|
||||||
|
measurements: 46/46 assets clean, library 59,012 tris, characters 4,482–5,474 tris (68–69% of
|
||||||
|
the 8,000 caps), `npc_base` only 1,204/2,488, 24-NPC 6.94 ms, and authored texel density
|
||||||
|
64–122 px/m (fabric effective 287–440 after the 4× repeat). That is below the skill's 128–256
|
||||||
|
background tier but the slot maps are neutral and tinted at runtime, so it is a recorded decision
|
||||||
|
point, not a defect. Added `blender_assets.py texels` (stdlib, per-slot px/m from GLB UV/world
|
||||||
|
area, fabric reported twice) and `inspect --json --out` (the glTF importer's INFO log is captured
|
||||||
|
in `stats.import_log`; Blender's startup banner still pollutes stdout). Baseline/review captures
|
||||||
|
run fine under `xvfb-run` into `tmp/`.
|
||||||
- **3D model re-evaluation first (2026-11-03):** `TODO.md` is re-planned around a new unversioned
|
- **3D model re-evaluation first (2026-11-03):** `TODO.md` is re-planned around a new unversioned
|
||||||
`3D model re-evaluation` track, ahead of character and local-map work, because the crowd-variant
|
`3D model re-evaluation` track, ahead of character and local-map work, because the crowd-variant
|
||||||
roster would otherwise multiply whatever the existing models get wrong. It scores all 8
|
roster would otherwise multiply whatever the existing models get wrong. It scores all 8
|
||||||
|
|||||||
@@ -28,16 +28,24 @@ except the rebuilt assets themselves.
|
|||||||
|
|
||||||
### A. Baseline, scorecard and routing
|
### A. Baseline, scorecard and routing
|
||||||
|
|
||||||
- [ ] Load `blender-director` plus the per-family specialists before touching geometry, and
|
- [x] Load `blender-director` plus the per-family specialists before touching geometry, and
|
||||||
record the skill stack and the advice that changed a decision in `docs/3d-model-review.md`.
|
record the skill stack and the advice that changed a decision in `docs/3d-model-review.md`.
|
||||||
- [ ] Re-run the neutral NPC baseline (48 frames), the final-context review (96 frames), the
|
(Routing, QA severity/verdict, texel-density tiers, character loop criteria and budget rules
|
||||||
|
recorded in the review doc; MCP-only steps translated to headless flatpak runs.)
|
||||||
|
- [x] Re-run the neutral NPC baseline (48 frames), the final-context review (96 frames), the
|
||||||
prop gallery and the 24-NPC budget on the current build; keep raw frames in `tmp/` and do
|
prop gallery and the 24-NPC budget on the current build; keep raw frames in `tmp/` and do
|
||||||
not replace committed sheets until a fix lands.
|
not replace committed sheets until a fix lands. (48/48 and 96/96 frames captured under
|
||||||
- [ ] Create `docs/3d-model-review.md` with a per-asset scorecard: silhouette, anatomy/proportion,
|
`xvfb-run`, none blank; raw sets in `tmp/model-baseline` and `tmp/model-review`; committed
|
||||||
|
sheets untouched. 24-NPC smoke still 6.94 ms / 168 meshes / 131,616 triangles.)
|
||||||
|
- [x] Create `docs/3d-model-review.md` with a per-asset scorecard: silhouette, anatomy/proportion,
|
||||||
topology/edge flow, UV/texel density, textures/materials, rig/weights, LOD/impostor,
|
topology/edge flow, UV/texel density, textures/materials, rig/weights, LOD/impostor,
|
||||||
collision/grounding, export contract, provenance, skill route, verdict (P0/P1/P2/accept).
|
collision/grounding, export contract, provenance, skill route, verdict (P0/P1/P2/accept).
|
||||||
- [ ] Collect the baseline measurements in one table: `blender_assets.py inspect`, `audit`, `fit`,
|
(Doc created with character/gear/prop/venue tables, verdict columns and a ranked candidate
|
||||||
`npc_proportions.py`, texel density, triangles/meshes, 24-NPC frame time.
|
list; unmeasured cells are marked `—` instead of being guessed.)
|
||||||
|
- [x] Collect the baseline measurements in one table: `blender_assets.py inspect`, `audit`, `fit`,
|
||||||
|
`npc_proportions.py`, texel density, triangles/meshes, 24-NPC frame time. (All seven captured;
|
||||||
|
`texels` and `inspect --json --out` were added to make the measurements repeatable. All 46
|
||||||
|
assets audit/inspect clean; texel density 64–122 px/m authored, 287–440 effective fabric.)
|
||||||
- [ ] Review the scorecard with the project owner, rank the P0/P1 gaps and decide fix vs accept
|
- [ ] Review the scorecard with the project owner, rank the P0/P1 gaps and decide fix vs accept
|
||||||
per item; record the decision and the reason in the review document.
|
per item; record the decision and the reason in the review document.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# 3D model review (2026-11-03)
|
||||||
|
|
||||||
|
Status: **baseline measured; scorecard open.** This is the working document for the unversioned
|
||||||
|
`3D model re-evaluation` track in [`TODO.md`](../TODO.md). It records what was measured, what the
|
||||||
|
Blender skill guidance says the targets are, the ranked candidate gaps, and the decisions that
|
||||||
|
still need the project owner. Nothing here re-opens the accepted 0.9.4 limits by itself.
|
||||||
|
|
||||||
|
Related documents: [`npc-visual-review.md`](npc-visual-review.md) (accepted 0.9.4 limits and
|
||||||
|
before/after sheets), [`npc-proportion-audit.md`](npc-proportion-audit.md) (proportion guide),
|
||||||
|
[`blender-assets.md`](blender-assets.md) (asset contract), [`asset-tooling.md`](asset-tooling.md)
|
||||||
|
(commands).
|
||||||
|
|
||||||
|
## Method and skill routing
|
||||||
|
|
||||||
|
The review is screenshot- and measurement-backed, following `qa-review` (screenshot set, defect
|
||||||
|
severity, explicit verdict) rather than an opinion pass:
|
||||||
|
|
||||||
|
| Family | Skills loaded | Criteria taken from them |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Routing and pipeline | `blender-director` | Plan → blockout → detail → validate → export; MCP steps translated to headless flatpak runs |
|
||||||
|
| Characters | `character-artist`, `retopology`, `rigging` | Eye/mouth/jaw/shoulder loops, quad-dominant deformation surfaces, 3-loop joints, no triangles on primary deformers |
|
||||||
|
| UVs and textures | `uv-workflow`, `texture-workflow`, `materials` | Texel-density tiers (hero 512–1024, standard 256–512, background 128–256 px/m), consistent density, no overlapping UVs |
|
||||||
|
| Budgets and structure | `asset-optimization`, `lod-pipeline` | Triangle/vertex budgets, naming, LOD tiers |
|
||||||
|
| Props, gear, foliage | `prop-artist`, `hard-surface`, `vegetation-artist` | Silhouette read, bevel/chamfer language, collision proxies, carded foliage |
|
||||||
|
| Look and verdict | `lookdev`, `qa-review` | Day/night beauty captures, ship / ship-with-notes / no-ship verdict |
|
||||||
|
|
||||||
|
Project contract and gates stay authoritative; see `AGENTS.md` §Local Blender skills. The skills
|
||||||
|
assume Blender MCP, which this project does not use: everything below came from the headless
|
||||||
|
`tools/blender_assets.py` / `tools/npc_pipeline.py` pipelines plus the Godot capture harnesses.
|
||||||
|
|
||||||
|
## What was captured and measured (2026-11-03)
|
||||||
|
|
||||||
|
| Evidence | Command | Result |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Contract audit | `python3 tools/blender_assets.py audit --json` | 46/46 assets clean; library 59,012 tris |
|
||||||
|
| Imported-GLB inspection | `blender_assets.py inspect` (all 46) | All clean; transforms applied, helpers ignored |
|
||||||
|
| Gear fit | `python3 tools/blender_assets.py fit --json` | Head width 0.315 m on all eight; clothing radius 0.190–0.260 m |
|
||||||
|
| Proportions | `python3 tools/npc_proportions.py report --json` | All eight inside the stylized guide |
|
||||||
|
| Texel density | `python3 tools/blender_assets.py texels --json` | New measurement, table below |
|
||||||
|
| Neutral baseline | `capture_npc_baseline.gd` → `tmp/model-baseline` (48 frames) + `npc_baseline.py sheet` | 48 frames, none blank; sheet in `tmp/` only |
|
||||||
|
| Final context | `capture_npc_review.gd` → `tmp/model-review` (96 frames) | 96 frames, none blank; raw frames stay local |
|
||||||
|
| Performance | `smoke_npc_budget.gd` | 24 avatars, 168 meshes, 131,616 tris, 6.94 ms of 50 ms |
|
||||||
|
|
||||||
|
Raw captures and JSON stay in the gitignored `tmp/`; committed review images remain the
|
||||||
|
`docs/blender_previews/sheet_*.jpg` set from 0.9.4/0.9.5 until a fix actually lands.
|
||||||
|
|
||||||
|
## Character measurements
|
||||||
|
|
||||||
|
Contract numbers from `audit` + `inspect`; density from `texels` (256² slot maps, authored and
|
||||||
|
with the runtime 4× fabric repeat).
|
||||||
|
|
||||||
|
| Character | Tris / verts | Height / feet | Materials | Authored td (px/m) | Fabric effective (px/m) | Verdict |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| `npc_casual` | 4,482 / 4,260 | 1.824 / +0.000 | 7 | 106.8–122.2 | 429–440 | Contract pass; visual accepted 0.9.4 |
|
||||||
|
| `npc_jacket` | 4,872 / 4,662 | 1.824 / +0.000 | 7 | 100.3–113.7 | 409–413 | Contract pass; visual accepted 0.9.4 |
|
||||||
|
| `npc_coat` | 5,474 / 5,265 | 1.824 / +0.000 | 7 | 75.3–81.4 | 307–310 | Contract pass; visual accepted 0.9.4 |
|
||||||
|
| `npc_vest` | 4,980 / 4,879 | 1.819 / +0.000 | 9 | 85.5–99.3 | 362–366 | Contract pass; visual accepted 0.9.4 |
|
||||||
|
| `npc_dress` | 4,900 / 4,719 | 1.825 / +0.000 | 7 | 81.9–89.0 | 338–339 | Contract pass; visual accepted 0.9.4 |
|
||||||
|
| `npc_hoodie` | 5,304 / 4,950 | 1.856 / +0.000 | 7 | 89.5–98.6 | 359–364 | Contract pass; visual accepted 0.9.4 |
|
||||||
|
| `npc_firefighter` | 5,344 / 5,494 | 1.819 / +0.000 | 9 | 64.0–75.1 | 287–290 | Contract pass; lowest density |
|
||||||
|
| `npc_guard` | 5,046 / 5,096 | 1.819 / +0.000 | 11 | 90.0–100.8 | 374–374 | Contract pass; visual accepted 0.9.4 |
|
||||||
|
| `npc_base` (fallback) | 1,204 / 2,488 | 1.836 / +0.000 | 5 | 70.1–72.9 | 4× on fabric | Contract pass; far below its 6,000/4,000 cap |
|
||||||
|
|
||||||
|
Blender-side `inspect` counts can differ by ±1 vertex from the glTF accessor count in `audit`
|
||||||
|
(normal-split rounding); both are recorded and neither changes a verdict.
|
||||||
|
|
||||||
|
Readings:
|
||||||
|
|
||||||
|
- Every character is inside 8,000/8,000 with the farthest at 5,474 tris (68% of budget) and 5,494
|
||||||
|
verts (69%), so there is headroom for targeted topology detail without a cap change.
|
||||||
|
- Surface area tracks clothing volume: the firefighter coat is the largest body (4.28 m²) and has
|
||||||
|
the lowest authored density; `npc_casual` is the smallest (2.44 m²) and the highest.
|
||||||
|
- **Authored density (64–122 px/m) sits below the skill's background tier (128–256 px/m).** The
|
||||||
|
runtime tint controls most of the look: `skin`, `hair`, `accessory` and the fixed detail slots
|
||||||
|
are neutral maps tinted at runtime, not painted skin, so density mostly controls subtle
|
||||||
|
weave/noise. Fabric is effective 287–440 px/m (standard tier) because of the 4× repeat. This is
|
||||||
|
a recorded note, not a defect, until a portrait shows texture starvation.
|
||||||
|
- Material counts 7–11 per character are fixed detail slots (eyes, rubber, hivis, reflective,
|
||||||
|
leather, metal), not extra tint slots.
|
||||||
|
|
||||||
|
## Scorecard skeleton
|
||||||
|
|
||||||
|
Verdicts use `qa-review` semantics: **P0** (blocker: wrong scale, broken normals, budget fail),
|
||||||
|
**P1** (major: silhouette/readability), **P2** (minor), **accept** (recorded limit). `—` means not
|
||||||
|
yet reviewed; this document is the place to fill it.
|
||||||
|
|
||||||
|
### Characters
|
||||||
|
|
||||||
|
| Character | Silhouette | Face @1.85 m | Hands/grip | Weights/pose | Cloth/hair | LOD | Verdict |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| `npc_casual` | accept (0.9.4) | P1 (faceted planes) | accept (open palm) | — | accept | accept (two-quad impostor) | pending owner |
|
||||||
|
| `npc_jacket` | accept | P1 | accept | — | accept | accept | pending owner |
|
||||||
|
| `npc_coat` | accept | P1 | accept | — | accept (hem 0.76 stride) | accept | pending owner |
|
||||||
|
| `npc_vest` | accept | P1 | accept | — | accept | accept | pending owner |
|
||||||
|
| `npc_dress` | accept; performer props dominate | P1 | accept | — | accept | accept | pending owner |
|
||||||
|
| `npc_hoodie` | accept (hooded criterion) | accept (lower face only) | accept | — | accept | accept | pending owner |
|
||||||
|
| `npc_firefighter` | accept | P1 | accept | — | accept | accept | pending owner |
|
||||||
|
| `npc_guard` | accept | P1 | accept | — | accept | accept | pending owner |
|
||||||
|
|
||||||
|
### Gear (18)
|
||||||
|
|
||||||
|
| Gear | Attach | Tris | Verdict |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `cap` | head | 328 | — (fit measured, visual pending) |
|
||||||
|
| `beanie` | head | 460 | — (unused by any character) |
|
||||||
|
| `hood` | head | 804 | — |
|
||||||
|
| `sunglasses` | head | 164 | accept (0.9.4 clearance) |
|
||||||
|
| `firefighter_helmet` | head | 672 | accept (0.9.4 + 3 cm runtime offset) |
|
||||||
|
| `backpack` | back | 380 | accept (0.9.4 strap refit) |
|
||||||
|
| `shoulder_bag` | back | 188 | — |
|
||||||
|
| `scba_tank` | back | 362 | — |
|
||||||
|
| `lanyard` | chest | 100 | accept (0.9.4 + 4 cm runtime offset) |
|
||||||
|
| `phone` | hand | 96 | accept (forward offset) |
|
||||||
|
| `cup` | hand | 188 | accept |
|
||||||
|
| `clipboard` | hand | 84 | accept |
|
||||||
|
| `umbrella` | hand | 134 | accept |
|
||||||
|
| `camera` | hand | 318 | accept |
|
||||||
|
| `protest_sign` | hand | 92 | accept (performer/protester read) |
|
||||||
|
| `flashlight` | hand | 168 | accept |
|
||||||
|
| `wand` | hand | 244 | accept |
|
||||||
|
| `radio` | hand | 220 | accept |
|
||||||
|
|
||||||
|
Gear total: 5,002 tris across 18 pieces (all far below the 1,500-per-piece cap).
|
||||||
|
|
||||||
|
### Props (20 library + 6 generated fallbacks)
|
||||||
|
|
||||||
|
Library total 13,608 tris; `marquee` (2,804) and `street_tree` (2,592) are the two heaviest and are
|
||||||
|
the first candidates for a `vegetation-artist`/lookdev pass.
|
||||||
|
|
||||||
|
| Prop | Tris | Verdict |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `bollard` | 138 | — |
|
||||||
|
| `bench` | 560 | — |
|
||||||
|
| `trash_bin` | 228 | — |
|
||||||
|
| `planter_box` | 1,324 | — |
|
||||||
|
| `newspaper_box` | 244 | — |
|
||||||
|
| `fire_hydrant` | 466 | — |
|
||||||
|
| `street_lamp` | 428 | — |
|
||||||
|
| `street_tree` | 2,592 | P1 candidate (flat-shaded clumps) |
|
||||||
|
| `palm_tree` | 400 | P1 candidate (same foliage language) |
|
||||||
|
| `round_planter` | 1,240 | — |
|
||||||
|
| `a_frame_sign` | 212 | — |
|
||||||
|
| `wall_lantern` | 180 | — |
|
||||||
|
| `traffic_cone` | 210 | — |
|
||||||
|
| `crowd_barrier` | 388 | — |
|
||||||
|
| `stanchion` | 434 | — |
|
||||||
|
| `handrail` | 268 | — |
|
||||||
|
| `security_podium` | 184 | — |
|
||||||
|
| `double_door` | 380 | — |
|
||||||
|
| `marquee` | 2,804 | — (emissive check pending) |
|
||||||
|
| `sedan` | 928 | — |
|
||||||
|
|
||||||
|
Generated fallbacks (`assets/models/generated/`): bench, bollard, fire_hydrant, newspaper_box,
|
||||||
|
planter_box, trash_bin. They are only used when the library prop is missing, so they are audited
|
||||||
|
for presence, not re-reviewed for art quality.
|
||||||
|
|
||||||
|
### Venues
|
||||||
|
|
||||||
|
| Venue | Measured (map-budget test) | Status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Hollywood Boulevard | 58,884 tris / 409 meshes | shape-language review pending |
|
||||||
|
| The Joke Shop | 22,680 tris / 454 meshes | shape-language review pending |
|
||||||
|
| TV Studio | — | shape-language review pending |
|
||||||
|
|
||||||
|
## Ranked candidate gaps
|
||||||
|
|
||||||
|
Ordered by expected player-visible gain per unit of work. None is approved for fixing yet; the
|
||||||
|
owner review decides.
|
||||||
|
|
||||||
|
1. **Face plane structure at portrait distance (P1, all eight).** The neutral sheet shows flat,
|
||||||
|
faceted cheek/brow planes and slit-like eyes at 1.85 m. `character-artist`/`retopology` guidance
|
||||||
|
asks for concentric eye loops and a 3-loop mouth. This is the largest readability gap and also
|
||||||
|
the most expensive (head rebuild + re-UV + re-AO per variant). Needs a targeted single-variant
|
||||||
|
prototype before committing to eight.
|
||||||
|
2. **No pose/weight evidence (unknown).** Idle, walk, sit, block and turn deformation has never been
|
||||||
|
captured as a review set, so shoulder/hip/neck collapse is unmeasured. Cheap to add and it
|
||||||
|
gates any claim that the rig is acceptable.
|
||||||
|
3. **Foliage language (P1, `street_tree`, `palm_tree`, `planter_box`, `round_planter`).** Flat-shaded
|
||||||
|
clumps against an otherwise readable set, already deferred once. `vegetation-artist` carded
|
||||||
|
clusters would fix it inside the existing triangle budget.
|
||||||
|
4. **Texel density below the background tier (note).** 64–122 px/m authored vs the skill's 128–256
|
||||||
|
background tier. Fabric is already standard-tier at runtime. Decide: accept because the maps are
|
||||||
|
neutral tinted detail, or raise only `skin`/`hair` to 512² for portrait distance.
|
||||||
|
5. **`npc_firefighter` density floor (P2).** 64 px/m with the largest coat area; split the coat UV
|
||||||
|
shell or raise the map size before the next character pass, whichever the density decision says.
|
||||||
|
6. **Hands at conversation distance (accept, revisit only if face work opens the budget).** Open
|
||||||
|
palm with `HAND_ITEM_FORWARD`; no finger bones exist, so a curl rig is a contract decision, not
|
||||||
|
a modelling tidy-up.
|
||||||
|
7. **Gear never reviewed as silhouette language (P2).** 18 pieces pass fit/contract but have no
|
||||||
|
prop-artist verdict; `beanie` is unused and could be retired or reassigned.
|
||||||
|
8. **Venue shape language (P2).** Hollywood and the Joke Shop have budget data but no
|
||||||
|
`environment-artist`/`archviz` review; the TV Studio has no recorded map-budget number in this
|
||||||
|
document yet.
|
||||||
|
|
||||||
|
## Decisions needed from the owner
|
||||||
|
|
||||||
|
- Accept the measured texel density as a stylized limitation, or fund a skin/hair resolution pass.
|
||||||
|
- Approve or reject a single-variant face/eye-loop prototype (and pick which variant to prototype
|
||||||
|
on) before any eight-character rebuild.
|
||||||
|
- Decide whether the pose/weight capture is required before the crowd-variant roster.
|
||||||
|
- Confirm foliage rebuild priority versus the map track (both touch the same street trees).
|
||||||
|
|
||||||
|
## Next actions
|
||||||
|
|
||||||
|
Tracked in [`TODO.md`](../TODO.md) `3D model re-evaluation`: the remaining `A. Baseline…` item is
|
||||||
|
this owner review; `B`/`C`/`D` carry the per-family work, and `E` adds the pose/weight,
|
||||||
|
collider-coverage and scorecard-coverage gates this document found missing.
|
||||||
@@ -21,6 +21,7 @@ This folder holds the project documentation, visual references and generated QA
|
|||||||
- [`tutorial.md`](tutorial.md) — first-shift tutorial beats
|
- [`tutorial.md`](tutorial.md) — first-shift tutorial beats
|
||||||
- [`npc-visual-review.md`](npc-visual-review.md) — NPC visual baseline, acceptance criteria and ranked gaps
|
- [`npc-visual-review.md`](npc-visual-review.md) — NPC visual baseline, acceptance criteria and ranked gaps
|
||||||
- [`npc-proportion-audit.md`](npc-proportion-audit.md) — NPC proportion ratios and stylized guide results
|
- [`npc-proportion-audit.md`](npc-proportion-audit.md) — NPC proportion ratios and stylized guide results
|
||||||
|
- [`3d-model-review.md`](3d-model-review.md) — 3D model re-evaluation: measurements, scorecard and ranked gaps
|
||||||
- [`development-status.md`](development-status.md) — implemented features, current gaps and active planning
|
- [`development-status.md`](development-status.md) — implemented features, current gaps and active planning
|
||||||
- [`accessibility.md`](accessibility.md) — accessibility statement and known gaps
|
- [`accessibility.md`](accessibility.md) — accessibility statement and known gaps
|
||||||
- [`known-limitations.md`](known-limitations.md) — release-candidate blockers and accepted limits
|
- [`known-limitations.md`](known-limitations.md) — release-candidate blockers and accepted limits
|
||||||
|
|||||||
+23
-5
@@ -144,6 +144,7 @@ flatpak run org.blender.Blender --background --python "$PWD/tools/blender_assets
|
|||||||
python3 tools/blender_assets.py sheet # docs/blender_previews/sheet_*.jpg
|
python3 tools/blender_assets.py sheet # docs/blender_previews/sheet_*.jpg
|
||||||
python3 tools/blender_assets.py audit # no Blender needed
|
python3 tools/blender_assets.py audit # no Blender needed
|
||||||
python3 tools/blender_assets.py fit # gear-vs-body fit report
|
python3 tools/blender_assets.py fit # gear-vs-body fit report
|
||||||
|
python3 tools/blender_assets.py texels # per-slot texel density (px/m) of the characters
|
||||||
godot --headless --path . --import
|
godot --headless --path . --import
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -160,8 +161,8 @@ flatpak run org.blender.Blender --background \
|
|||||||
--python "$PWD/tools/blender_assets.py" -- inspect \
|
--python "$PWD/tools/blender_assets.py" -- inspect \
|
||||||
--file "$PWD/assets/models/blender/npc/npc_casual.glb" \
|
--file "$PWD/assets/models/blender/npc/npc_casual.glb" \
|
||||||
--file "$PWD/tmp/downloads/SomeCharacter.glb"
|
--file "$PWD/tmp/downloads/SomeCharacter.glb"
|
||||||
# machine-readable, for intake scripts
|
# machine-readable, for intake scripts (write to a file: Blender prints a banner first)
|
||||||
... -- inspect --file "$PWD/tmp/x.glb" --json
|
... -- inspect --file "$PWD/tmp/x.glb" --json --out "$PWD/tmp/x.json"
|
||||||
# force a contract family instead of inferring it from the path
|
# force a contract family instead of inferring it from the path
|
||||||
... -- inspect --file "$PWD/tmp/x.glb" --category npc
|
... -- inspect --file "$PWD/tmp/x.glb" --category npc
|
||||||
```
|
```
|
||||||
@@ -182,11 +183,28 @@ Caveats:
|
|||||||
pollute bounds or the unskinned list.
|
pollute bounds or the unskinned list.
|
||||||
- Bounds and bone rests are converted from Blender Z-up to Godot Y-up (`x, z, -y`),
|
- Bounds and bone rests are converted from Blender Z-up to Godot Y-up (`x, z, -y`),
|
||||||
matching the contract bands and the audit's coordinate space.
|
matching the contract bands and the audit's coordinate space.
|
||||||
- Numbers are Blender-side measurements. They agree with `audit` on project assets
|
- Numbers are Blender-side measurements. Triangles match `audit` on project assets; the seam-split
|
||||||
(same triangle and glTF vertex counts), but `audit` stays the single final gate:
|
vertex count can differ by ±1 because the importer rounds normals. `audit` stays the single final
|
||||||
it is stdlib-only, runs in CI, and matches what the engine imports.
|
gate: it is stdlib-only, runs in CI, and matches what the engine imports.
|
||||||
- `inspect` never writes or adapts the file; scale/grounding/weld/retarget work is the
|
- `inspect` never writes or adapts the file; scale/grounding/weld/retarget work is the
|
||||||
separate (not yet built) `adapt` step.
|
separate (not yet built) `adapt` step.
|
||||||
|
- `--json` also captures the importer's INFO log in `stats.import_log` instead of printing it.
|
||||||
|
Blender still prints its own version banner before the script runs, so intake scripts should use
|
||||||
|
`--json --out <file>` rather than parsing stdout.
|
||||||
|
|
||||||
|
### Texel density (`texels`)
|
||||||
|
|
||||||
|
`texel_density()` and the `texels` command compute `resolution * sqrt(UV area / world area)` per
|
||||||
|
material slot straight from the GLB, so the number does not depend on island packing. The NPC slot
|
||||||
|
maps are 256², and cloth/pants are reported twice: authored density and effective density with the
|
||||||
|
runtime 4× repeat from `Materials.NPC_FABRIC_UV_SCALE`. Current values and interpretation are in
|
||||||
|
[`3d-model-review.md`](3d-model-review.md).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/blender_assets.py texels # all eight characters
|
||||||
|
python3 tools/blender_assets.py texels --json # machine-readable
|
||||||
|
python3 tools/blender_assets.py texels --only npc_coat
|
||||||
|
```
|
||||||
|
|
||||||
## Local map development (planned)
|
## Local map development (planned)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ the conversation-distance hand pass
|
|||||||
[`sheet_npc_hands_compare.jpg`](blender_previews/sheet_npc_hands_compare.jpg), and the
|
[`sheet_npc_hands_compare.jpg`](blender_previews/sheet_npc_hands_compare.jpg), and the
|
||||||
forward-grip pass [`sheet_npc_hands_grip_compare.jpg`](blender_previews/sheet_npc_hands_grip_compare.jpg).
|
forward-grip pass [`sheet_npc_hands_grip_compare.jpg`](blender_previews/sheet_npc_hands_grip_compare.jpg).
|
||||||
|
|
||||||
|
The 2026-11-03 skill-guided re-evaluation, its measurements (texel density, budgets, imported
|
||||||
|
bounds) and the open scorecard live in [`3d-model-review.md`](3d-model-review.md). It does not
|
||||||
|
change the accepted limits below.
|
||||||
|
|
||||||
## What was captured
|
## What was captured
|
||||||
|
|
||||||
`src/test/capture_npc_baseline.gd` stages all eight library characters in a
|
`src/test/capture_npc_baseline.gd` stages all eight library characters in a
|
||||||
|
|||||||
+123
-1
@@ -35,6 +35,8 @@ Conventions (Blender is Z-up; the glTF exporter converts to Godot Y-up):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import struct
|
import struct
|
||||||
@@ -1791,6 +1793,89 @@ def audit_asset(category, name):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Texel density (UV area vs world area, per material slot)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# The NPC slot maps are 256² (`npc_pipeline.TEX_SIZE`); cloth and pants repeat
|
||||||
|
# the authored UVs 4× at runtime via `Materials.NPC_FABRIC_UV_SCALE`, so their
|
||||||
|
# effective density is four times the authored number.
|
||||||
|
TEXEL_RESOLUTION = 256
|
||||||
|
TEXEL_RUNTIME_UV_SCALE = {"cloth": 4.0, "pants": 4.0}
|
||||||
|
|
||||||
|
|
||||||
|
def _tri_area3(a, b, c) -> float:
|
||||||
|
ux, uy, uz = b[0] - a[0], b[1] - a[1], b[2] - a[2]
|
||||||
|
vx, vy, vz = c[0] - a[0], c[1] - a[1], c[2] - a[2]
|
||||||
|
cx = uy * vz - uz * vy
|
||||||
|
cy = uz * vx - ux * vz
|
||||||
|
cz = ux * vy - uy * vx
|
||||||
|
return 0.5 * math.sqrt(cx * cx + cy * cy + cz * cz)
|
||||||
|
|
||||||
|
|
||||||
|
def _tri_area2(a, b, c) -> float:
|
||||||
|
return 0.5 * abs((b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def texel_density(name: str, path: Path | None = None) -> dict | None:
|
||||||
|
"""Per-slot texel density (px/m) for a character GLB.
|
||||||
|
|
||||||
|
texel density = resolution * sqrt(UV area / world surface area), so the
|
||||||
|
number is independent of how the island is packed. Effective density also
|
||||||
|
applies the runtime UV repeat for cloth/pants.
|
||||||
|
"""
|
||||||
|
path = path or asset_path("npc", name)
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
_magic, _version, _length, _size, gltf, binary = _read_glb(path)
|
||||||
|
except (struct.error, ValueError, KeyError):
|
||||||
|
return None
|
||||||
|
materials = [m.get("name", "") for m in gltf.get("materials", [])]
|
||||||
|
slots: dict[str, dict] = {}
|
||||||
|
for mesh in gltf.get("meshes", []):
|
||||||
|
for prim in mesh.get("primitives", []):
|
||||||
|
attrs = prim.get("attributes", {})
|
||||||
|
if ("POSITION" not in attrs or "TEXCOORD_0" not in attrs
|
||||||
|
or prim.get("indices") is None):
|
||||||
|
continue
|
||||||
|
mat_index = prim.get("material")
|
||||||
|
slot = materials[mat_index] if mat_index is not None and mat_index < len(materials) else ""
|
||||||
|
positions = _accessor(gltf, binary, attrs["POSITION"])
|
||||||
|
uvs = _accessor(gltf, binary, attrs["TEXCOORD_0"])
|
||||||
|
indices = [row[0] for row in _accessor(gltf, binary, prim["indices"])]
|
||||||
|
row = slots.setdefault(slot or "unassigned",
|
||||||
|
{"triangles": 0, "world_area_m2": 0.0, "uv_area": 0.0})
|
||||||
|
for k in range(0, len(indices) - 2, 3):
|
||||||
|
i0, i1, i2 = indices[k], indices[k + 1], indices[k + 2]
|
||||||
|
if max(i0, i1, i2) >= len(positions) or max(i0, i1, i2) >= len(uvs):
|
||||||
|
continue
|
||||||
|
row["world_area_m2"] += _tri_area3(positions[i0], positions[i1], positions[i2])
|
||||||
|
row["uv_area"] += _tri_area2(uvs[i0], uvs[i1], uvs[i2])
|
||||||
|
row["triangles"] += 1
|
||||||
|
for slot, row in slots.items():
|
||||||
|
world = row["world_area_m2"]
|
||||||
|
uv = row["uv_area"]
|
||||||
|
authored = TEXEL_RESOLUTION * math.sqrt(uv / world) if world > 0.0 else 0.0
|
||||||
|
row["td_px_per_m"] = round(authored, 1)
|
||||||
|
row["td_runtime_px_per_m"] = round(authored * TEXEL_RUNTIME_UV_SCALE.get(slot, 1.0), 1)
|
||||||
|
row["world_area_m2"] = round(world, 3)
|
||||||
|
row["uv_area"] = round(uv, 4)
|
||||||
|
values = [row["td_px_per_m"] for row in slots.values() if row["td_px_per_m"] > 0.0]
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"resolution": TEXEL_RESOLUTION,
|
||||||
|
"slots": slots,
|
||||||
|
"min_td": min(values) if values else 0.0,
|
||||||
|
"max_td": max(values) if values else 0.0,
|
||||||
|
"total_world_area_m2": round(sum(r["world_area_m2"] for r in slots.values()), 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def texel_report() -> list[dict]:
|
||||||
|
return [row for row in (texel_density(name) for name in CHARACTERS) if row]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Intake inspection (import a GLB the way Blender sees it)
|
# Intake inspection (import a GLB the way Blender sees it)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1925,7 +2010,11 @@ def inspect_glb(path, category=None) -> dict:
|
|||||||
return {"ok": False, "error": f"missing file {file_path}", "issues": [],
|
return {"ok": False, "error": f"missing file {file_path}", "issues": [],
|
||||||
"warnings": [], "category": category or "external"}
|
"warnings": [], "category": category or "external"}
|
||||||
reset_scene()
|
reset_scene()
|
||||||
|
# The glTF importer logs per-mesh INFO lines to stdout, which would corrupt
|
||||||
|
# `--json` output; keep the log in stats instead of printing it.
|
||||||
|
import_log = io.StringIO()
|
||||||
try:
|
try:
|
||||||
|
with contextlib.redirect_stdout(import_log), contextlib.redirect_stderr(import_log):
|
||||||
bpy.ops.import_scene.gltf(filepath=str(file_path))
|
bpy.ops.import_scene.gltf(filepath=str(file_path))
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return {"ok": False, "error": f"import failed: {exc}", "issues": [],
|
return {"ok": False, "error": f"import failed: {exc}", "issues": [],
|
||||||
@@ -1983,6 +2072,7 @@ def inspect_glb(path, category=None) -> dict:
|
|||||||
"world_bounds": world,
|
"world_bounds": world,
|
||||||
"transform_delta": delta,
|
"transform_delta": delta,
|
||||||
"transforms_applied": applied,
|
"transforms_applied": applied,
|
||||||
|
"import_log": import_log.getvalue().strip().splitlines()[-3:],
|
||||||
"detail": objects,
|
"detail": objects,
|
||||||
}
|
}
|
||||||
result = analyse_inspection(stats)
|
result = analyse_inspection(stats)
|
||||||
@@ -2318,10 +2408,35 @@ def main(argv=None):
|
|||||||
choices=["", "npc", "npc_gear", "props", "external"],
|
choices=["", "npc", "npc_gear", "props", "external"],
|
||||||
help="override the path-derived contract category")
|
help="override the path-derived contract category")
|
||||||
insp.add_argument("--json", action="store_true")
|
insp.add_argument("--json", action="store_true")
|
||||||
|
insp.add_argument("--out", default="",
|
||||||
|
help="with --json, write to this file (Blender prints a startup banner)")
|
||||||
|
tex = sub.add_parser("texels", help="per-slot texel density of the character GLBs")
|
||||||
|
tex.add_argument("--only", default="")
|
||||||
|
tex.add_argument("--json", action="store_true")
|
||||||
sub.add_parser("sheet")
|
sub.add_parser("sheet")
|
||||||
sub.add_parser("list")
|
sub.add_parser("list")
|
||||||
args = ap.parse_args(raw)
|
args = ap.parse_args(raw)
|
||||||
|
|
||||||
|
if args.cmd == "texels":
|
||||||
|
names = [n.strip() for n in args.only.split(",") if n.strip()] or list(CHARACTERS)
|
||||||
|
report = [row for row in (texel_density(n) for n in names) if row]
|
||||||
|
if not report:
|
||||||
|
print("no built character assets; run build first", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(report, indent=2))
|
||||||
|
else:
|
||||||
|
for entry in report:
|
||||||
|
print(f"{entry['name']:16s} {entry['resolution']}px maps "
|
||||||
|
f"area={entry['total_world_area_m2']:.2f} m2 "
|
||||||
|
f"td={entry['min_td']:.0f}-{entry['max_td']:.0f} px/m")
|
||||||
|
for slot, row in sorted(entry["slots"].items()):
|
||||||
|
note = " (runtime x%.0f)" % TEXEL_RUNTIME_UV_SCALE[slot] \
|
||||||
|
if slot in TEXEL_RUNTIME_UV_SCALE else ""
|
||||||
|
print(f" {slot:12s} tris={row['triangles']:5d} area={row['world_area_m2']:5.2f} m2 "
|
||||||
|
f"uv={row['uv_area']:.3f} td={row['td_px_per_m']:6.0f} "
|
||||||
|
f"effective={row['td_runtime_px_per_m']:6.0f} px/m{note}")
|
||||||
|
return 0
|
||||||
if args.cmd == "inspect":
|
if args.cmd == "inspect":
|
||||||
if bpy is None:
|
if bpy is None:
|
||||||
print("inspect must run inside Blender:\n flatpak run org.blender.Blender "
|
print("inspect must run inside Blender:\n flatpak run org.blender.Blender "
|
||||||
@@ -2330,7 +2445,14 @@ def main(argv=None):
|
|||||||
return 2
|
return 2
|
||||||
results = [inspect_glb(f, args.category or None) for f in args.file]
|
results = [inspect_glb(f, args.category or None) for f in args.file]
|
||||||
if args.json:
|
if args.json:
|
||||||
print(json.dumps(results, indent=2))
|
payload = json.dumps(results, indent=2)
|
||||||
|
if args.out:
|
||||||
|
Path(args.out).write_text(payload + "\n", encoding="utf-8")
|
||||||
|
print(f"wrote {args.out}")
|
||||||
|
else:
|
||||||
|
# Blender prints its version banner before this script runs, so
|
||||||
|
# scripts should prefer --out for machine-readable output.
|
||||||
|
print(payload)
|
||||||
else:
|
else:
|
||||||
for result in results:
|
for result in results:
|
||||||
print_inspection(result)
|
print_inspection(result)
|
||||||
|
|||||||
@@ -95,6 +95,37 @@ class BuiltAssetTests(unittest.TestCase):
|
|||||||
self.assertTrue((ba.SRC_DIR / ".gdignore").is_file())
|
self.assertTrue((ba.SRC_DIR / ".gdignore").is_file())
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(ba.MANIFEST.is_file(), "Blender assets not built")
|
||||||
|
class TexelDensityTests(unittest.TestCase):
|
||||||
|
def test_character_slots_report_density_and_area(self):
|
||||||
|
report = ba.texel_density("npc_casual")
|
||||||
|
self.assertEqual(report["resolution"], 256)
|
||||||
|
self.assertEqual(set(report["slots"]), set(ba.SLOTS) | {"eye_white", "rubber"})
|
||||||
|
for slot in ba.SLOTS:
|
||||||
|
row = report["slots"][slot]
|
||||||
|
self.assertGreater(row["triangles"], 0, slot)
|
||||||
|
self.assertGreater(row["world_area_m2"], 0.0, slot)
|
||||||
|
self.assertGreater(row["td_px_per_m"], 30.0, slot)
|
||||||
|
self.assertGreater(report["total_world_area_m2"], 1.0)
|
||||||
|
self.assertLess(report["total_world_area_m2"], 6.0)
|
||||||
|
|
||||||
|
def test_fabric_slots_apply_the_runtime_uv_repeat(self):
|
||||||
|
report = ba.texel_density("npc_casual")
|
||||||
|
for slot, factor in ba.TEXEL_RUNTIME_UV_SCALE.items():
|
||||||
|
row = report["slots"][slot]
|
||||||
|
self.assertAlmostEqual(row["td_runtime_px_per_m"],
|
||||||
|
row["td_px_per_m"] * factor, places=0)
|
||||||
|
skin = report["slots"]["skin"]
|
||||||
|
self.assertEqual(skin["td_px_per_m"], skin["td_runtime_px_per_m"])
|
||||||
|
|
||||||
|
def test_base_fallback_is_measurable_from_its_own_path(self):
|
||||||
|
from pathlib import Path
|
||||||
|
path = ba.ROOT / "assets" / "models" / "generated" / "npc_base.glb"
|
||||||
|
report = ba.texel_density("npc_base", path)
|
||||||
|
self.assertIsNotNone(report)
|
||||||
|
self.assertGreater(report["min_td"], 30.0)
|
||||||
|
|
||||||
|
|
||||||
class InspectionTests(unittest.TestCase):
|
class InspectionTests(unittest.TestCase):
|
||||||
"""`inspect` reports on imported GLBs; the contract logic is pure and tested
|
"""`inspect` reports on imported GLBs; the contract logic is pure and tested
|
||||||
here with synthetic measurements so Blender is not a test dependency."""
|
here with synthetic measurements so Blender is not a test dependency."""
|
||||||
|
|||||||
Reference in new issue
Block a user