Phase 6.5 checkpoint
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
# Creating Character Parts for the Game (Blender -> Engine)
|
||||
|
||||
This is the contract a character model must satisfy to work with the
|
||||
character pipeline: the catalog-driven modular body
|
||||
(`CharacterSlotSystem`), the deterministic NPC look assignment
|
||||
(`NpcPopulationSystem`), the animation trees, and the hair physics.
|
||||
Companion document for cars: `../vehicles/CAR_MODELING.md`.
|
||||
|
||||
The reference implementations are `edited-normal-male.blend` and
|
||||
`edited-normal-female.blend` (base bodies) plus the
|
||||
`clothes-<sex>-<slot>.blend` garment files in this directory.
|
||||
|
||||
## 1. Export pipeline
|
||||
|
||||
All steps are driven by `assets/blender/characters/CMakeLists.txt` and
|
||||
run in Blender batch mode; from the project root:
|
||||
|
||||
```bash
|
||||
cmake --build build-vscode --target edited-blends # base bodies + animations
|
||||
cmake --build build-vscode # full pipeline incl. clothes
|
||||
```
|
||||
|
||||
Pipeline per sex (`male` / `female`):
|
||||
|
||||
1. `edited-normal-<sex>.blend` is the base body. `copy_animations.py`
|
||||
copies the Mixamo animation library (`assets/blender/mixamo/`) into
|
||||
it — idle, walking, running, swimming, treading_water, sitting,
|
||||
sitting-ground, sitting-vehicle, ...
|
||||
2. Each `clothes-<sex>-<slot>.blend` holds garment meshes for one slot.
|
||||
`process_clothes.py` fits them to the body (weight transfer from the
|
||||
reference body parts, seam fixes), `combine_clothes.py` bakes the
|
||||
combined meshes for garment combinations (see §3).
|
||||
3. `consolidate.py` appends everything into
|
||||
`edited-normal-<sex>-consolidated.blend`.
|
||||
4. `export_models2.py` exports the consolidated blend through
|
||||
blender2ogre: one `.mesh` per catalog mesh object, the shared
|
||||
`.skeleton`, `.material` files, the texture atlases
|
||||
(`clothes-atlas.png`, `clothes-atlas-normal.png`, `face-atlas.png`),
|
||||
a `.glb` preview, and one **`body_part_<mesh data name>.json`
|
||||
catalog entry per mesh object** carrying the required custom
|
||||
properties (see §2).
|
||||
|
||||
Outputs land in `build-vscode/characters/<sex>/` and are staged into
|
||||
the editScene runtime `characters/` directory (OGRE resource group
|
||||
`Characters`). The engine discovers catalog entries by scanning every
|
||||
resource group for `body_part_*.json` — no manual registration step.
|
||||
|
||||
**blender2ogre names `.mesh` files after the mesh DATA name** (not the
|
||||
object name) — set both, and never reuse another blend's mesh data
|
||||
names: everything exports into the same directory and silently
|
||||
overwrites.
|
||||
|
||||
## 2. Catalog entry contract (custom properties on the MESH OBJECT)
|
||||
|
||||
`export_models2.py` writes a catalog JSON for every mesh object that
|
||||
carries all three required custom properties (Properties panel ->
|
||||
Object -> Custom Properties):
|
||||
|
||||
| Property | Type | Meaning |
|
||||
|----------|------|---------|
|
||||
| `age` | string | age band, currently only `adult` exists |
|
||||
| `sex` | string | `male` / `female` |
|
||||
| `slot` | string | `top` / `bottom` / `feet` / `hair` / `face` |
|
||||
| `layer` | int | 0 = nude base, 1 = underwear (and hair), 2 = clothes (default 0) |
|
||||
| `garments` | string | `;`-separated garment ids this mesh CONTAINS |
|
||||
| `clothing_tags` | string | `;`-separated extra tags |
|
||||
|
||||
`shape_keys` are collected automatically from the mesh data (the
|
||||
`Basis` key is excluded). Hair entries additionally get
|
||||
`own_skeleton: true` and `attach_to_bone: "mixamorig:Head"` (see §5).
|
||||
Garment ids are automatically duplicated into `tags`.
|
||||
|
||||
Resulting entry (`body_part_male_BodyTop_coat.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"age": "adult",
|
||||
"sex": "male",
|
||||
"slot": "top",
|
||||
"layer": 2,
|
||||
"garments": ["coat1"],
|
||||
"tags": ["coat1", "coat"],
|
||||
"shape_keys": ["neck-width-plus", "fat"],
|
||||
"mesh": "male_BodyTop_coat.mesh"
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Slots, layers and mesh resolution
|
||||
|
||||
The engine builds a character from one mesh per slot
|
||||
(`CharacterSlotSystem::buildCharacter`): the **face** mesh is the
|
||||
master (it carries the full body skeleton); every other slot's mesh
|
||||
shares that skeleton instance (`shareSkeletonInstanceWith`) — so every
|
||||
body/garment mesh must be skinned to the SAME armature
|
||||
(`mixamorig:` bone names) with compatible bind poses.
|
||||
|
||||
Resolution per slot (`resolveMesh`), given the record's
|
||||
`inlineOutfitLevel` and `inlineSlotSelections`:
|
||||
|
||||
- **outfitLevel 2 (dressed)**: the selected layer-2 mesh; when a
|
||||
layer-1 garment is also selected, a COMBINED mesh whose `garments`
|
||||
array contains both ids is preferred (e.g.
|
||||
`male_BodyBottom_Panties001_Pants0001.mesh` contains
|
||||
`panties0001;pants0001`). Combined meshes are baked by
|
||||
`combine_clothes.py` — a new garment only needs its own entry, the
|
||||
combinations are generated.
|
||||
- **outfitLevel 1 (underwear)**: the selected layer-1 mesh.
|
||||
- **outfitLevel 0 (nude)**: the layer-0 entry tagged
|
||||
**`detailed-nude`** when one exists for the slot (phase 6.C.1) —
|
||||
the plain layer-0 meshes are simplified composition bases meant to
|
||||
disappear under garments and lack anatomical detail. Author at most
|
||||
one `detailed-nude` variant per slot per sex (`top`/`bottom`/`feet`);
|
||||
slots without one keep the composition base, so the variants can be
|
||||
added one slot at a time. Tag it via `clothing_tags =
|
||||
"detailed-nude"` on a layer-0 mesh.
|
||||
- Final fallback: the shortest-named layer-0 entry (Blender `.001`
|
||||
duplicates are penalised), then any entry.
|
||||
|
||||
### Authoring a `detailed-nude` part (whole-body-part replacement)
|
||||
|
||||
A detailed-nude mesh is a COMPLETE standalone body part that replaces
|
||||
the simplified composition base at runtime when the character wears
|
||||
nothing. It is NOT built from the base part by the pipeline and no
|
||||
geometry is ever deleted from it:
|
||||
|
||||
- The hidden-geometry deletion (`combine_clothes.py`) only runs on
|
||||
copies of the base meshes a garment explicitly names in its
|
||||
`ref_part` property. No garment references your new mesh, so it
|
||||
passes through the pipeline untouched, and the plain base part must
|
||||
stay in the blend — garments still need it as their combination
|
||||
target.
|
||||
- The replacement happens at mesh-resolution time only: outfitLevel 0
|
||||
shows the detailed part, outfitLevel >= 1 goes back to the
|
||||
composition bases/combined meshes.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Model the part as a NEW mesh object in the base body blend
|
||||
`edited-normal-<sex>.blend`, next to the existing base parts
|
||||
(`BodyTop`/`BodyBottom`/...). One detailed variant per slot per
|
||||
sex at most (`top`/`bottom`/`feet`); the engine takes the first
|
||||
`detailed-nude` entry it finds for the slot.
|
||||
2. Skin it to the SAME `mixamorig:` armature with the same bind pose,
|
||||
max 4 bone influences per vertex. (`consolidate.py` re-parents it
|
||||
to the armature named by its `sex` property and adds an Armature
|
||||
modifier if missing, but the weights are your job.)
|
||||
3. Match the boundary loops of the base part it replaces (neck, waist,
|
||||
wrists, ankles) vertex-for-vertex in position: the consolidate
|
||||
step's seam fixer unifies shape-key offsets and normals for
|
||||
vertices at identical positions across parts, so matching
|
||||
boundaries come out seamless; mismatched ones will show gaps
|
||||
against the face/hands/neighbouring slots.
|
||||
4. Copy the same shape keys (`fat`, ...) onto it — helper scripts in
|
||||
this directory: `transfer_shape_keys.py`,
|
||||
`propagate_shape_keys.py`, `add_missing_shape_keys.py`.
|
||||
5. Custom properties on the mesh OBJECT: `age`/`sex`/`slot` as usual,
|
||||
`layer` = 0, `clothing_tags` = `detailed-nude`, no `garments`.
|
||||
6. Give object AND mesh data a unique name (e.g.
|
||||
`male_BodyTop_detailed`); the mesh data name becomes the `.mesh`
|
||||
file name.
|
||||
7. Rebuild (`edited-blends` target or full build). No CMake or code
|
||||
changes are needed: consolidate/export discover the part by its
|
||||
custom properties and emit `body_part_<mesh data name>.json`
|
||||
automatically, and the runtime starts using it for nude characters
|
||||
of that slot.
|
||||
|
||||
The town population picks looks deterministically per NPC:
|
||||
`fnv1a(npcKey|slot)` indexes into the layer-1 hair list and (when
|
||||
`outfitLevel >= 2`, the public default) the layer-2 garment lists.
|
||||
Adding or reordering catalog entries changes some NPC looks — that is
|
||||
fine — but never make the catalog itself random: the same source
|
||||
assets must always produce the same town.
|
||||
|
||||
## 4. Skeleton, skinning and animation
|
||||
|
||||
- Skeleton: Mixamo hierarchy, bone names prefixed `mixamorig:`
|
||||
(139 bones). All body/garment parts share it; only hair has its own
|
||||
armature (§5).
|
||||
- **GLES2 hardware skinning**: the runtime injects RTSS hardware
|
||||
skinning (`weight_count 4`, per-part compact bone palette) into every
|
||||
character mesh, so a part may reference at most 4 bone influences per
|
||||
vertex and its compact palette must fit the GPU uniform budget — keep
|
||||
garment weights tight around the bones they actually need (the
|
||||
exporter's weight transfer already does this). Blend weights are
|
||||
repacked to zero-padded float4 at load, so 1-3 influence parts are
|
||||
fine. `EDITSCENE_NO_HWSKIN=1` falls back to software skinning for
|
||||
debugging.
|
||||
- **Facing**: character meshes face **+Z** (registry records carry
|
||||
`frontAxis = +Z`); the walk/run clips' root motion also translates
|
||||
along +Z. A part authored facing -Z moonwalks.
|
||||
- **Root motion**: locomotion uses root motion — the clip's root track
|
||||
must translate the character (do not bake in-place walks).
|
||||
- **Animations** live on the shared skeleton and are referenced by NAME
|
||||
from `animation_tree.json`. State names the trees expect:
|
||||
`idle`, `walking`, `running`, `driving` (-> `sitting-vehicle`),
|
||||
`swimming`, `swim-idle` (-> `treading_water`), `swimming-fast`,
|
||||
plus action states `sitting` / `sitting-ground`.
|
||||
`sitting-vehicle` is authored so the seat pin IS the character's base
|
||||
(root) position — butt/hands/feet land correctly with zero offset
|
||||
(see CAR_MODELING.md §5).
|
||||
- **Shape keys** (e.g. `fat`, `neck-width-plus`) export as OGRE poses;
|
||||
the registry record's `inlineShapeKeyWeights` drives them (pose index
|
||||
= catalog order, Basis excluded). Meshes are shared between
|
||||
characters: when a record has all-zero weights the runtime strips the
|
||||
mesh's poses for performance, so shape-keyed and plain characters
|
||||
must not force the same mesh through both paths in one session.
|
||||
|
||||
## 5. Hair
|
||||
|
||||
Hair meshes export with their OWN armature (`own_skeleton: true`) and
|
||||
attach to the master skeleton's `mixamorig:Head` bone. At runtime
|
||||
`HairPhysicsSystem` builds a Jolt ragdoll over the hair bone chain:
|
||||
the root body is pinned to the head, the rest simulates.
|
||||
|
||||
Modelling consequences:
|
||||
|
||||
- Keep the hair bone chain short and rooted at the scalp; the root
|
||||
bone's bind transform must sit on the head.
|
||||
- Hair needs no shape keys and no layer-2 interaction; it lives on
|
||||
layer 1 with a `hair` tag.
|
||||
- Distant LODs (§6) disable hair physics — hair must still look
|
||||
acceptable as a rigid head-attached mesh.
|
||||
|
||||
## 6. LOD scheme (phase 6.C.3/6.C.4 — implemented)
|
||||
|
||||
Ogre mesh LODs are NOT used for characters (skinned-mesh risk and they
|
||||
cannot merge parts); instead the pipeline generates mesh VARIANTS and
|
||||
`CharacterSlotSystem` swaps whole meshes by camera distance (2 m
|
||||
hysteresis on every boundary):
|
||||
|
||||
- **LOD0 (< 10 m)**: modular, full detail — the normal catalog parts.
|
||||
- **LOD1 (10-30 m)**: per-part `<mesh base>_lod1.mesh` variants — same
|
||||
geometry, shape keys removed, UVs remapped into one shared 2048x2048
|
||||
atlas (`charlod_<sex>-atlas.png`) with a single `charlod_<sex>`
|
||||
material per sex. Hair slots are skipped (hidden/impostor-baked at
|
||||
distance anyway). Parts without a `_lod1` variant keep their base
|
||||
mesh, so the scheme degrades gracefully.
|
||||
- **LOD2 (30-70 m)**: whole-character impostors
|
||||
`charlod_<sex>_thin_lod2.mesh` / `charlod_<sex>_fat_lod2.mesh` — the
|
||||
deterministic default outfit (per slot: highest layer, first entry by
|
||||
name) joined into ONE mesh on the shared armature, `fat` shape key
|
||||
baked at 0/1, hair rigid-bound to `mixamorig:Head`, atlas material.
|
||||
The impostor skin palette is pruned to the 23 core bones (Root,
|
||||
Hips, Spine/1/2, Neck, Head, Shoulder/Arm/ForeArm/Hand and
|
||||
UpLeg/Leg/Foot/ToeBase per side — no fingers): groups of dropped
|
||||
bones (fingers, eyes, bust, hair/coat-skirt physics chains) are
|
||||
remapped to the nearest kept ancestor and merged, keeping hardware
|
||||
skinning well inside the 64-bone budget. The shared skeleton and
|
||||
its animations are untouched, so no animation conversion is needed.
|
||||
All part entities are hidden; the master entity keeps animating the
|
||||
shared skeleton and the impostor follows it via
|
||||
`shareSkeletonInstanceWith`. Hair physics (ragdoll) is OFF.
|
||||
- **LOD3 (70-100 m, till despawn)**: `charlod_<sex>_<variant>_lod3.mesh` —
|
||||
collapse-decimated LOD2, ~400 triangles (hard limit 400).
|
||||
- Interiors need only LOD0/LOD1.
|
||||
|
||||
Generation is automated in the export pipeline:
|
||||
`assets/blender/characters/generate_lods.py` runs per sex on the
|
||||
consolidated blend (CMake step `edited-normal-<sex>-lods.blend`),
|
||||
`export_models2.py` appends the result via its 6th argument and exports
|
||||
the LOD meshes with the SAME shared armature. A manifest
|
||||
(`charlod_<sex>.json`: atlas file + impostor mesh names) is written for
|
||||
reference; the runtime uses name conventions + `resourceExists`, not
|
||||
the manifest. `CharacterSlotSystem::prewarmShaders` also warms the
|
||||
`*_lod1.mesh` / `charlod_*.mesh` shaders.
|
||||
|
||||
Known simplifications (revisit in phase 10.5/11.5 if visible):
|
||||
impostor hair is rigid head-bound, the impostor outfit is the default
|
||||
per-slot pick regardless of the NPC's actual garments, and the atlas
|
||||
downscales each source texture into a grid cell (7 images -> 3x3 grid
|
||||
of ~682 px cells). Known issue: the atlas looks wrong in-game
|
||||
(likely a UV remap bug in generate_lods.py — reported 2026-10-25,
|
||||
deferred; check remap_mesh_uvs against the exported OGRE UV V
|
||||
convention first).
|
||||
|
||||
## 7. Character prefabs
|
||||
|
||||
Characters are saved as `prefabs/character.<id>.json` (phase 6.C.2 —
|
||||
the `character.` prefix distinguishes them from scene prefabs; the
|
||||
editor's prefab save writes the new prefix, old `char_<id>.json` paths
|
||||
migrate on save). A prefab carries the registry record reference,
|
||||
shape-key weights and slot selections — the meshes themselves always
|
||||
come from the catalog.
|
||||
|
||||
## 8. Verifying a new part
|
||||
|
||||
```bash
|
||||
# rebuild the character pipeline, then from the demo-taxi build dir:
|
||||
cd build-vscode/src/features/editScene/demos/demo-taxi
|
||||
xvfb-run -a ./demoTaxi --headless demo_scene_exterior.json \
|
||||
--force-pos 3444 12 3425 0 --print-detail 6000
|
||||
# prints spawned NPCs with their resolved master mesh / age / sex
|
||||
```
|
||||
|
||||
Check: the part appears on NPCs (deterministic pick — several NPCs may
|
||||
need to spawn before a new garment shows), no T-pose (animation tree
|
||||
bound), no floating/crumpling parts (skeleton mismatch — the runtime
|
||||
auto-detects and head-attaches, which hides but does not fix bad
|
||||
weights), shape keys apply on the player character sheet. The full
|
||||
contract suite:
|
||||
|
||||
```bash
|
||||
./demoTaxi --headless --test-motion
|
||||
./demoTaxi --headless --test-detail
|
||||
```
|
||||
@@ -53,8 +53,10 @@ add_custom_command(
|
||||
COMMAND ${BLENDER} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py --
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend
|
||||
${CMAKE_BINARY_DIR}/characters/male/normal-male.glb
|
||||
"AUTO"
|
||||
"male"
|
||||
tmp-edited-male.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-lods.blend
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/male/normal-male.glb
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/male/normal-male.scene
|
||||
@@ -62,6 +64,7 @@ add_custom_command(
|
||||
DEPENDS ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py
|
||||
${VRM_IMPORTED_BLENDS}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-lods.blend
|
||||
# ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-hair.stamp
|
||||
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
|
||||
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
|
||||
@@ -74,8 +77,10 @@ add_custom_command(
|
||||
COMMAND ${BLENDER} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py --
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated.blend
|
||||
${CMAKE_BINARY_DIR}/characters/female/normal-female.glb
|
||||
"AUTO"
|
||||
"female"
|
||||
tmp-edited-female.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-lods.blend
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/female/normal-female.glb
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/female/normal-female.scene
|
||||
@@ -83,6 +88,7 @@ add_custom_command(
|
||||
DEPENDS ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py
|
||||
${VRM_IMPORTED_BLENDS}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-lods.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
|
||||
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
|
||||
VERBATIM
|
||||
@@ -480,3 +486,29 @@ foreach (SEX ${SEX_LIST})
|
||||
set(SLOT_INPUT ${SLOT_OUTPUT})
|
||||
endforeach()
|
||||
endforeach()
|
||||
|
||||
# Character LOD generation (demo-taxi phase 6.C.3/6.C.4): from each
|
||||
# consolidated blend generate per-part "_lod1" atlas variants and
|
||||
# whole-character charlod impostors (LOD2 thin/fat, LOD3 <= 400 tris),
|
||||
# the shared 2048 atlas and the charlod_<sex>.json manifest. The
|
||||
# export_models2 commands above append the lods blend so the impostors
|
||||
# link the same skeleton as the body parts.
|
||||
foreach (SEX ${SEX_LIST})
|
||||
add_custom_command(
|
||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-lods.blend
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory
|
||||
${CMAKE_BINARY_DIR}/characters/${SEX}
|
||||
COMMAND ${BLENDER} -b -Y
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-consolidated.blend
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/generate_lods.py --
|
||||
${SEX}
|
||||
${CMAKE_BINARY_DIR}/characters/${SEX}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-lods.blend
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-lods.blend
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-consolidated.blend
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/generate_lods.py
|
||||
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
|
||||
COMMENT "Generating character LOD meshes for ${SEX}"
|
||||
)
|
||||
endforeach()
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
# generate_lods.py - character LOD asset generation (phase 6.C.3)
|
||||
#
|
||||
# Run in Blender batch mode with the consolidated blend already loaded:
|
||||
#
|
||||
# blender -b -Y edited-normal-<sex>-consolidated.blend \
|
||||
# -P generate_lods.py -- <sex> <out_dir> <lods_blend_out>
|
||||
#
|
||||
# Produces, per sex:
|
||||
# * <out_dir>/charlod_<sex>-atlas.png - shared 2048x2048 LOD atlas
|
||||
# * <out_dir>/charlod_<sex>.json - manifest
|
||||
# * LOD1 object variants `<part>_lod1` - same geometry, no shape keys,
|
||||
# UVs remapped into the atlas, one shared material `charlod_<sex>`
|
||||
# (hair slot is skipped - hair is hidden/impostor-baked at distance)
|
||||
# * LOD2 impostors `charlod_<sex>_{thin,fat}_lod2` - the deterministic
|
||||
# default outfit joined into one mesh skinned to the body armature
|
||||
# * LOD3 impostors `charlod_<sex>_{thin,fat}_lod3` - decimate of LOD2,
|
||||
# <= 400 triangles (hard limit)
|
||||
# * saves everything into <lods_blend_out>
|
||||
#
|
||||
# Documented simplifications:
|
||||
# * Hair re-skinning: hair meshes (which may reference their OWN hair
|
||||
# armature) are rigid-bound 100% to the body skeleton's head bone
|
||||
# (mixamorig:Head) for the impostors. At LOD2+ distances hair physics
|
||||
# is disabled anyway (CHARACTER_MODELING.md section 5/6).
|
||||
# * Default outfit: per slot the highest available layer wins
|
||||
# (2 dressed > 1 underwear > 0 nude), first entry sorted by object
|
||||
# name. This is deterministic and mirrors what the town population
|
||||
# would resolve for a dressed NPC.
|
||||
# * Core-bone palette: LOD2/3 impostors skin only to CORE_BONE_NAMES
|
||||
# (root/spine/neck/head/limbs, 23 bones, no fingers). Groups of
|
||||
# dropped bones (fingers, eyes, bust, hair/coat-skirt physics
|
||||
# chains) are remapped to the nearest kept ancestor and merged.
|
||||
# The shared skeleton and its animations are unchanged, so no
|
||||
# animation conversion is needed.
|
||||
# * UV flipping: none needed - Blender image pixel rows and UV space
|
||||
# share the same bottom-left origin, so UV v maps linearly onto the
|
||||
# pixel row index (v = y / height). The saved PNG keeps that row
|
||||
# order, which is the same convention the existing pipeline exports.
|
||||
# * Source UVs are clamped to [0,1] before remapping so remapped UVs
|
||||
# always stay inside their atlas cell (affects only tiling UVs).
|
||||
#
|
||||
# The script is idempotent: previously generated _lod1/_lod2/_lod3 and
|
||||
# charlod_* objects, the charlod_<sex> material and the atlas image are
|
||||
# wiped at start.
|
||||
|
||||
import bpy
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
ATLAS_SIZE = 2048
|
||||
LOD3_MAX_TRIS = 400
|
||||
REQUIRED_PROPS = {"age", "sex", "slot"}
|
||||
SKIP_PREFIXES = ("cs_", "WGT-", "MCH-", "ORG-")
|
||||
SLOTS_OUTFIT = ("face", "top", "bottom", "feet")
|
||||
|
||||
# Combined LOD2/3 impostors skin to this core palette only (root, spine,
|
||||
# neck, head, limbs - no fingers, no eyes/bust, no physics chains).
|
||||
# 23 bones total, comfortably inside the 64-bone hardware-skinning
|
||||
# budget. Groups of dropped bones are remapped to the nearest kept
|
||||
# ancestor (fingers -> Hand, Eye/Bust -> Head/Spine2, coat skirt ->
|
||||
# Leg). The armature and its animations are NOT touched: the impostor
|
||||
# shares the full skeleton at runtime, only the vertex weights change,
|
||||
# so no animation conversion is needed.
|
||||
CORE_BONE_NAMES = {
|
||||
"Root",
|
||||
"mixamorig:Hips",
|
||||
"mixamorig:Spine",
|
||||
"mixamorig:Spine1",
|
||||
"mixamorig:Spine2",
|
||||
"mixamorig:Neck",
|
||||
"mixamorig:Head",
|
||||
}
|
||||
for _side in ("Left", "Right"):
|
||||
for _part in ("Shoulder", "Arm", "ForeArm", "Hand",
|
||||
"UpLeg", "Leg", "Foot", "ToeBase"):
|
||||
CORE_BONE_NAMES.add("mixamorig:" + _side + _part)
|
||||
MAX_IMPOSTOR_BONES = 64
|
||||
|
||||
|
||||
def log(msg):
|
||||
print("[generate_lods] " + msg, flush=True)
|
||||
|
||||
|
||||
def parse_args():
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:]
|
||||
if len(argv) < 3:
|
||||
raise RuntimeError(
|
||||
"usage: blender -b -Y <consolidated.blend> -P generate_lods.py"
|
||||
" -- <sex> <out_dir> <lods_blend_out>")
|
||||
return argv[0], argv[1], argv[2]
|
||||
|
||||
|
||||
def strip_suffix(name):
|
||||
"""Strip Blender .### duplicate suffix from a datablock name."""
|
||||
return re.sub(r"\.\d{3}$", "", name)
|
||||
|
||||
|
||||
def collect_catalog_objects():
|
||||
"""Same classification rule as consolidate.py."""
|
||||
result = []
|
||||
for ob in bpy.data.objects:
|
||||
if ob.type != "MESH":
|
||||
continue
|
||||
if not all(p in ob.keys() for p in REQUIRED_PROPS):
|
||||
continue
|
||||
if ob.name.startswith(SKIP_PREFIXES):
|
||||
continue
|
||||
result.append(ob)
|
||||
return sorted(result, key=lambda o: o.name)
|
||||
|
||||
|
||||
def find_armature(sex):
|
||||
arm = bpy.data.objects.get(sex)
|
||||
if arm and arm.type == "ARMATURE":
|
||||
return arm
|
||||
for ob in bpy.data.objects:
|
||||
if ob.type == "ARMATURE" and ob.name == sex:
|
||||
return ob
|
||||
raise RuntimeError("shared armature '%s' not found" % sex)
|
||||
|
||||
|
||||
def find_head_bone(armature):
|
||||
bones = armature.data.bones
|
||||
if "mixamorig:Head" in bones:
|
||||
return "mixamorig:Head"
|
||||
for b in bones:
|
||||
if "Head" in b.name:
|
||||
log("WARNING: mixamorig:Head not found, using '%s'" % b.name)
|
||||
return b.name
|
||||
raise RuntimeError("no head bone found in armature '%s'"
|
||||
% armature.name)
|
||||
|
||||
|
||||
def base_color_image(mat):
|
||||
"""Image whose content the mesh UVs address: the Base Color texture
|
||||
of the Principled BSDF, falling back to the first TEX_IMAGE node."""
|
||||
if not mat or not mat.use_nodes or not mat.node_tree:
|
||||
return None
|
||||
for node in mat.node_tree.nodes:
|
||||
if node.type == "BSDF_PRINCIPLED":
|
||||
inp = node.inputs.get("Base Color")
|
||||
if inp and inp.is_linked:
|
||||
src = inp.links[0].from_node
|
||||
if src.type == "TEX_IMAGE" and src.image:
|
||||
return src.image
|
||||
for node in mat.node_tree.nodes:
|
||||
if node.type == "TEX_IMAGE" and node.image:
|
||||
log("WARNING: material '%s' has no direct Base Color texture,"
|
||||
" falling back to image '%s'" % (mat.name, node.image.name))
|
||||
return node.image
|
||||
return None
|
||||
|
||||
|
||||
def cleanup_previous(sex):
|
||||
"""Idempotency: wipe previously generated data."""
|
||||
doomed = []
|
||||
for ob in list(bpy.data.objects):
|
||||
if (ob.name.startswith("charlod_") or ob.name.endswith("_lod1")
|
||||
or ob.name.endswith("_lod2") or ob.name.endswith("_lod3")):
|
||||
doomed.append(ob)
|
||||
for ob in doomed:
|
||||
log("wiping previous object '%s'" % ob.name)
|
||||
bpy.data.objects.remove(ob, do_unlink=True)
|
||||
for me in list(bpy.data.meshes):
|
||||
if me.users == 0 and (me.name.startswith("charlod_")
|
||||
or me.name.endswith("_lod1")
|
||||
or me.name.endswith("_lod2")
|
||||
or me.name.endswith("_lod3")):
|
||||
bpy.data.meshes.remove(me)
|
||||
mat = bpy.data.materials.get("charlod_" + sex)
|
||||
if mat:
|
||||
bpy.data.materials.remove(mat)
|
||||
for img in list(bpy.data.images):
|
||||
if img.name.startswith("charlod_%s-atlas" % sex):
|
||||
bpy.data.images.remove(img)
|
||||
|
||||
|
||||
def build_atlas(catalog, sex, out_dir):
|
||||
"""Paste every distinct base-color image into a grid cell of a new
|
||||
ATLAS_SIZE x ATLAS_SIZE image. Returns (atlas_image, rects) where
|
||||
rects maps dedupe key -> (u0, v0, u1, v1)."""
|
||||
canonical = {} # dedupe key -> bpy.types.Image
|
||||
for ob in catalog:
|
||||
for slot in ob.material_slots:
|
||||
img = base_color_image(slot.material)
|
||||
if img is None:
|
||||
continue
|
||||
key = strip_suffix(img.name)
|
||||
if key not in canonical or not key.endswith(
|
||||
strip_suffix(canonical[key].name)):
|
||||
# prefer the unsuffixed instance
|
||||
if key not in canonical or img.name == key:
|
||||
canonical[key] = img
|
||||
keys = sorted(canonical.keys())
|
||||
log("atlas source images (%d): %s" % (len(keys), keys))
|
||||
|
||||
n = max(1, math.ceil(math.sqrt(len(keys))))
|
||||
cell = ATLAS_SIZE // n
|
||||
log("atlas grid %dx%d, cell %dpx" % (n, n, cell))
|
||||
|
||||
atlas_name = "charlod_%s-atlas.png" % sex
|
||||
atlas = bpy.data.images.new(atlas_name, width=ATLAS_SIZE,
|
||||
height=ATLAS_SIZE, alpha=True)
|
||||
buf = np.zeros(ATLAS_SIZE * ATLAS_SIZE * 4, dtype=np.float32)
|
||||
grid = buf.reshape(ATLAS_SIZE, ATLAS_SIZE, 4)
|
||||
|
||||
rects = {}
|
||||
for idx, key in enumerate(keys):
|
||||
img = canonical[key]
|
||||
w, h = img.size
|
||||
if w == 0 or h == 0:
|
||||
log("WARNING: image '%s' has no pixel data, skipped"
|
||||
% img.name)
|
||||
continue
|
||||
col = idx % n
|
||||
row = idx // n # rows counted from the bottom, like UV v
|
||||
scale = min(cell / w, cell / h)
|
||||
pw = max(1, int(round(w * scale)))
|
||||
ph = max(1, int(round(h * scale)))
|
||||
tmp = img.copy()
|
||||
tmp.scale(pw, ph)
|
||||
px = np.empty(pw * ph * 4, dtype=np.float32)
|
||||
tmp.pixels.foreach_get(px)
|
||||
bpy.data.images.remove(tmp)
|
||||
tile = px.reshape(ph, pw, 4)
|
||||
x0 = col * cell + (cell - pw) // 2
|
||||
y0 = row * cell + (cell - ph) // 2
|
||||
grid[y0:y0 + ph, x0:x0 + pw, :] = tile
|
||||
rects[key] = (x0 / ATLAS_SIZE, y0 / ATLAS_SIZE,
|
||||
(x0 + pw) / ATLAS_SIZE, (y0 + ph) / ATLAS_SIZE)
|
||||
log(" cell (%d,%d): '%s' %dx%d -> %dx%d at (%d,%d) rect %s"
|
||||
% (col, row, key, w, h, pw, ph, x0, y0,
|
||||
["%.4f" % v for v in rects[key]]))
|
||||
|
||||
atlas.pixels.foreach_set(buf)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
atlas_path = os.path.join(out_dir, atlas_name)
|
||||
atlas.filepath_raw = atlas_path
|
||||
atlas.file_format = "PNG"
|
||||
atlas.save()
|
||||
log("atlas saved to %s" % atlas_path)
|
||||
return atlas, rects
|
||||
|
||||
|
||||
def make_atlas_material(sex, atlas):
|
||||
mat_name = "charlod_" + sex
|
||||
mat = bpy.data.materials.new(mat_name)
|
||||
mat.use_nodes = True
|
||||
nt = mat.node_tree
|
||||
bsdf = next(n for n in nt.nodes if n.type == "BSDF_PRINCIPLED")
|
||||
tex = nt.nodes.new("ShaderNodeTexImage")
|
||||
tex.image = atlas
|
||||
nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
|
||||
mat.blend_method = "OPAQUE"
|
||||
log("created shared material '%s' with texture '%s'"
|
||||
% (mat_name, atlas.name))
|
||||
return mat
|
||||
|
||||
|
||||
def slot_rects(ob, rects):
|
||||
"""Per material slot of ob: atlas rect of its base-color image."""
|
||||
result = []
|
||||
for slot in ob.material_slots:
|
||||
img = base_color_image(slot.material)
|
||||
if img is None:
|
||||
result.append(None)
|
||||
continue
|
||||
result.append(rects.get(strip_suffix(img.name)))
|
||||
return result
|
||||
|
||||
|
||||
def remap_mesh_uvs(mesh, rects, owner):
|
||||
"""Affine-remap loop UVs into the atlas cells of their material slot.
|
||||
Source UVs are clamped to [0,1] first so output stays in [0,1]."""
|
||||
uv_layer = mesh.uv_layers.active
|
||||
if uv_layer is None:
|
||||
log("WARNING: '%s' has no UV layer, UVs not remapped" % owner)
|
||||
return
|
||||
nloops = len(mesh.loops)
|
||||
if nloops == 0:
|
||||
return
|
||||
uvs = np.empty(nloops * 2, dtype=np.float32)
|
||||
uv_layer.data.foreach_get("uv", uvs)
|
||||
uvs = uvs.reshape(-1, 2)
|
||||
np.clip(uvs, 0.0, 1.0, out=uvs)
|
||||
slot_of_loop = np.zeros(nloops, dtype=np.int32)
|
||||
for poly in mesh.polygons:
|
||||
slot_of_loop[poly.loop_start:
|
||||
poly.loop_start + poly.loop_total] = \
|
||||
poly.material_index
|
||||
for si, rect in enumerate(rects):
|
||||
if rect is None:
|
||||
continue
|
||||
mask = slot_of_loop == si
|
||||
if not mask.any():
|
||||
continue
|
||||
u0, v0, u1, v1 = rect
|
||||
sub = uvs[mask]
|
||||
sub[:, 0] = u0 + sub[:, 0] * (u1 - u0)
|
||||
sub[:, 1] = v0 + sub[:, 1] * (v1 - v0)
|
||||
uvs[mask] = sub
|
||||
uv_layer.data.foreach_set("uv", uvs.ravel())
|
||||
lo = uvs.min()
|
||||
hi = uvs.max()
|
||||
if lo < -1e-4 or hi > 1.0 + 1e-4:
|
||||
log("WARNING: '%s' UVs out of [0,1] after remap: %f..%f"
|
||||
% (owner, lo, hi))
|
||||
|
||||
|
||||
def apply_atlas_material(mesh, atlas_mat):
|
||||
mesh.materials.clear()
|
||||
mesh.materials.append(atlas_mat)
|
||||
for poly in mesh.polygons:
|
||||
poly.material_index = 0
|
||||
|
||||
|
||||
def copy_object(ob, new_name, collection):
|
||||
dup = ob.copy()
|
||||
dup.data = ob.data.copy()
|
||||
dup.name = new_name
|
||||
dup.data.name = new_name
|
||||
collection.objects.link(dup)
|
||||
return dup
|
||||
|
||||
|
||||
def generate_lod1(catalog, rects, atlas_mat, sex):
|
||||
"""LOD1: per catalog mesh (hair excluded) a shape-key-free copy with
|
||||
atlas UVs and the single shared material."""
|
||||
made = []
|
||||
for ob in catalog:
|
||||
if ob.get("slot") == "hair":
|
||||
continue
|
||||
new_name = ob.name + "_lod1"
|
||||
dup = copy_object(ob, new_name, bpy.context.collection)
|
||||
dup.shape_key_clear()
|
||||
for key in ob.keys():
|
||||
dup[key] = ob[key]
|
||||
dup["lod"] = 1
|
||||
remap_mesh_uvs(dup.data, slot_rects(ob, rects), new_name)
|
||||
apply_atlas_material(dup.data, atlas_mat)
|
||||
made.append(dup)
|
||||
log("LOD1 '%s': %d polys, props copied, lod=1"
|
||||
% (new_name, len(dup.data.polygons)))
|
||||
return made
|
||||
|
||||
|
||||
def pick_default_outfit(catalog):
|
||||
"""Deterministic default outfit: per slot the highest available
|
||||
layer (2 > 1 > 0), first object sorted by name. Hair: layer-1,
|
||||
skipping bald/placeholder meshes (< 100 polys or 'bald' in name)."""
|
||||
picks = {}
|
||||
for slot in SLOTS_OUTFIT + ("hair",):
|
||||
entries = [ob for ob in catalog if ob.get("slot") == slot]
|
||||
if slot == "hair":
|
||||
entries = [ob for ob in entries
|
||||
if int(ob.get("layer", 0)) >= 1
|
||||
and "bald" not in ob.name.lower()
|
||||
and len(ob.data.polygons) >= 100]
|
||||
if not entries:
|
||||
log("WARNING: no catalog entry for slot '%s'" % slot)
|
||||
continue
|
||||
best_layer = max(int(ob.get("layer", 0)) for ob in entries)
|
||||
best = sorted((ob for ob in entries
|
||||
if int(ob.get("layer", 0)) == best_layer),
|
||||
key=lambda o: o.name)[0]
|
||||
picks[slot] = best
|
||||
log("default outfit slot '%s': '%s' (layer %d)"
|
||||
% (slot, best.name, best_layer))
|
||||
return picks
|
||||
|
||||
|
||||
def bake_shape_keys(ob, fat_value, depsgraph):
|
||||
"""Evaluate ob with the 'fat' shape key set to fat_value and all
|
||||
shape keys baked away. Returns a new shape-key-free mesh (vertex
|
||||
groups, UVs and material slots preserved)."""
|
||||
dup = ob.copy()
|
||||
dup.data = ob.data.copy()
|
||||
bpy.context.collection.objects.link(dup)
|
||||
if dup.data.shape_keys:
|
||||
for kb in dup.data.shape_keys.key_blocks:
|
||||
if kb.name == "fat":
|
||||
kb.value = fat_value
|
||||
eval_ob = dup.evaluated_get(depsgraph)
|
||||
mesh = bpy.data.meshes.new_from_object(
|
||||
eval_ob, preserve_all_data_layers=True, depsgraph=depsgraph)
|
||||
bpy.data.objects.remove(dup, do_unlink=True)
|
||||
return mesh
|
||||
|
||||
|
||||
def rigid_bind_to_head(obj, head_bone):
|
||||
"""Re-skin every vertex 100% to the head bone (hair shortcut, see
|
||||
header)."""
|
||||
for vg in list(obj.vertex_groups):
|
||||
obj.vertex_groups.remove(vg)
|
||||
vg = obj.vertex_groups.new(name=head_bone)
|
||||
vg.add(list(range(len(obj.data.vertices))), 1.0, "REPLACE")
|
||||
log(" rigid-bound %d verts of '%s' to '%s'"
|
||||
% (len(obj.data.vertices), obj.name, head_bone))
|
||||
|
||||
|
||||
def core_bone_target(name, parents):
|
||||
"""Nearest kept ancestor (or the bone itself) for the impostor
|
||||
palette. Bones outside the mixamorig hierarchy (control rig)
|
||||
fall back to the skeleton root."""
|
||||
cur = name
|
||||
while cur is not None:
|
||||
if cur in CORE_BONE_NAMES:
|
||||
return cur
|
||||
cur = parents.get(cur)
|
||||
return "Root"
|
||||
|
||||
|
||||
def prune_to_core_bones(obj, armature):
|
||||
"""Collapse the impostor skin palette to CORE_BONE_NAMES. Vertex
|
||||
groups of dropped bones are remapped to their nearest kept
|
||||
ancestor and their weights merged per vertex. The armature, its
|
||||
bone hierarchy and all animations stay untouched - only the
|
||||
impostor's vertex weights change."""
|
||||
parents = {b.name: b.parent.name if b.parent else None
|
||||
for b in armature.data.bones}
|
||||
lut = {}
|
||||
for vg in obj.vertex_groups:
|
||||
target = core_bone_target(vg.name, parents)
|
||||
if target not in parents:
|
||||
raise RuntimeError("core bone '%s' missing from armature"
|
||||
% target)
|
||||
lut[vg.index] = target
|
||||
merged = []
|
||||
unweighted = False
|
||||
for v in obj.data.vertices:
|
||||
acc = {}
|
||||
for g in v.groups:
|
||||
if g.weight <= 0.0:
|
||||
continue
|
||||
name = lut[g.group]
|
||||
acc[name] = acc.get(name, 0.0) + g.weight
|
||||
if not acc:
|
||||
unweighted = True
|
||||
merged.append(acc)
|
||||
for vg in list(obj.vertex_groups):
|
||||
obj.vertex_groups.remove(vg)
|
||||
used = sorted({name for acc in merged for name in acc})
|
||||
if unweighted:
|
||||
used.append("Root")
|
||||
groups = {name: obj.vertex_groups.new(name=name) for name in used}
|
||||
for vi, acc in enumerate(merged):
|
||||
if not acc:
|
||||
groups["Root"].add([vi], 1.0, "REPLACE")
|
||||
continue
|
||||
for name, w in acc.items():
|
||||
if w > 1e-6:
|
||||
groups[name].add([vi], w, "REPLACE")
|
||||
log("impostor '%s': skin palette pruned to %d core bones%s: %s"
|
||||
% (obj.name, len(groups),
|
||||
" (unweighted verts pinned to Root)" if unweighted else "",
|
||||
", ".join(sorted(groups))))
|
||||
if len(groups) > MAX_IMPOSTOR_BONES:
|
||||
raise RuntimeError("impostor '%s' palette %d > %d bones"
|
||||
% (obj.name, len(groups), MAX_IMPOSTOR_BONES))
|
||||
|
||||
|
||||
def join_objects(objects, name, armature):
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for ob in objects:
|
||||
ob.select_set(True)
|
||||
bpy.context.view_layer.objects.active = objects[0]
|
||||
bpy.ops.object.join()
|
||||
joined = bpy.context.view_layer.objects.active
|
||||
joined.name = name
|
||||
joined.data.name = name
|
||||
joined.parent = armature
|
||||
joined.matrix_parent_inverse = armature.matrix_world.inverted()
|
||||
arm_mod = next((m for m in joined.modifiers
|
||||
if m.type == "ARMATURE"), None)
|
||||
if arm_mod is None:
|
||||
arm_mod = joined.modifiers.new(name="Armature", type="ARMATURE")
|
||||
arm_mod.object = armature
|
||||
return joined
|
||||
|
||||
|
||||
def count_tris(mesh):
|
||||
mesh.calc_loop_triangles()
|
||||
return len(mesh.loop_triangles)
|
||||
|
||||
|
||||
def generate_lod2(catalog, rects, atlas_mat, armature, head_bone, sex):
|
||||
made = {}
|
||||
for variant, fat_value in (("thin", 0.0), ("fat", 1.0)):
|
||||
picks = pick_default_outfit(catalog)
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
parts = []
|
||||
for slot in SLOTS_OUTFIT + ("hair",):
|
||||
ob = picks.get(slot)
|
||||
if ob is None:
|
||||
continue
|
||||
mesh = bake_shape_keys(ob, fat_value, depsgraph)
|
||||
part = bpy.data.objects.new(
|
||||
"charlod_tmp_%s_%s" % (variant, slot), mesh)
|
||||
bpy.context.collection.objects.link(part)
|
||||
part.matrix_world = ob.matrix_world
|
||||
remap_mesh_uvs(mesh, slot_rects(ob, rects),
|
||||
"lod2/%s/%s" % (variant, ob.name))
|
||||
apply_atlas_material(mesh, atlas_mat)
|
||||
if slot == "hair":
|
||||
rigid_bind_to_head(part, head_bone)
|
||||
parts.append(part)
|
||||
name = "charlod_%s_%s_lod2" % (sex, variant)
|
||||
joined = join_objects(parts, name, armature)
|
||||
prune_to_core_bones(joined, armature)
|
||||
tris = count_tris(joined.data)
|
||||
n_vg = len(joined.vertex_groups)
|
||||
has_head = any(vg.name == head_bone
|
||||
for vg in joined.vertex_groups)
|
||||
log("LOD2 '%s': %d polys, %d tris, %d vertex groups, "
|
||||
"head group: %s" % (name, len(joined.data.polygons), tris,
|
||||
n_vg, has_head))
|
||||
made[variant] = joined
|
||||
return made
|
||||
|
||||
|
||||
def decimated_mesh(src_obj, ratio, depsgraph=None):
|
||||
tmp = src_obj.copy()
|
||||
tmp.data = src_obj.data.copy()
|
||||
bpy.context.collection.objects.link(tmp)
|
||||
mod = tmp.modifiers.new(name="Decimate", type="DECIMATE")
|
||||
mod.decimate_type = "COLLAPSE"
|
||||
mod.ratio = ratio
|
||||
# refresh so the just-added modifier is part of the evaluation
|
||||
bpy.context.view_layer.update()
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
eval_ob = tmp.evaluated_get(depsgraph)
|
||||
mesh = bpy.data.meshes.new_from_object(
|
||||
eval_ob, preserve_all_data_layers=True, depsgraph=depsgraph)
|
||||
bpy.data.objects.remove(tmp, do_unlink=True)
|
||||
return mesh
|
||||
|
||||
|
||||
def generate_lod3(lod2_map, armature, sex):
|
||||
made = {}
|
||||
for variant, src in lod2_map.items():
|
||||
base_tris = count_tris(src.data)
|
||||
name = "charlod_%s_%s_lod3" % (sex, variant)
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
if base_tris <= LOD3_MAX_TRIS:
|
||||
mesh = src.data.copy()
|
||||
log("LOD3 '%s': already %d tris (<= %d), no decimate"
|
||||
% (name, base_tris, LOD3_MAX_TRIS))
|
||||
else:
|
||||
# binary search for the highest ratio still <= LOD3_MAX_TRIS;
|
||||
# a 0-tri result means the ratio collapsed everything and is
|
||||
# too LOW, not acceptable.
|
||||
lo, hi = 0.001, 1.0
|
||||
best_mesh, best_tris, best_ratio = None, 0, lo
|
||||
for _ in range(20):
|
||||
ratio = (lo + hi) / 2.0
|
||||
mesh = decimated_mesh(src, ratio, depsgraph)
|
||||
tris = count_tris(mesh)
|
||||
log(" decimate try ratio %.4f -> %d tris"
|
||||
% (ratio, tris))
|
||||
if tris > LOD3_MAX_TRIS:
|
||||
hi = ratio
|
||||
bpy.data.meshes.remove(mesh)
|
||||
elif tris == 0:
|
||||
lo = ratio
|
||||
bpy.data.meshes.remove(mesh)
|
||||
else:
|
||||
lo = ratio
|
||||
if best_mesh is not None:
|
||||
bpy.data.meshes.remove(best_mesh)
|
||||
best_mesh, best_tris, best_ratio = mesh, tris, ratio
|
||||
if best_mesh is None:
|
||||
raise RuntimeError(
|
||||
"LOD3 '%s': decimate never reached <= %d tris"
|
||||
% (name, LOD3_MAX_TRIS))
|
||||
mesh = best_mesh
|
||||
log("LOD3 '%s': decimate ratio %.4f -> %d tris "
|
||||
"(limit %d, source %d)"
|
||||
% (name, best_ratio, best_tris, LOD3_MAX_TRIS,
|
||||
base_tris))
|
||||
obj = bpy.data.objects.new(name, mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
# A fresh object may already carry the source's vertex groups
|
||||
# via new_from_object; only fill in any missing ones so the
|
||||
# decimated mesh's group indices keep resolving to the same
|
||||
# (core-pruned) bones on export.
|
||||
for vg in src.vertex_groups:
|
||||
if obj.vertex_groups.get(vg.name) is None:
|
||||
obj.vertex_groups.new(name=vg.name)
|
||||
obj.parent = armature
|
||||
obj.matrix_parent_inverse = armature.matrix_world.inverted()
|
||||
arm_mod = obj.modifiers.new(name="Armature", type="ARMATURE")
|
||||
arm_mod.object = armature
|
||||
made[variant] = obj
|
||||
return made
|
||||
|
||||
|
||||
def write_manifest(sex, out_dir):
|
||||
manifest = {
|
||||
"atlas": "charlod_%s-atlas.png" % sex,
|
||||
"variants": ["thin", "fat"],
|
||||
"lod2": {v: "charlod_%s_%s_lod2.mesh" % (sex, v)
|
||||
for v in ("thin", "fat")},
|
||||
"lod3": {v: "charlod_%s_%s_lod3.mesh" % (sex, v)
|
||||
for v in ("thin", "fat")},
|
||||
}
|
||||
path = os.path.join(out_dir, "charlod_%s.json" % sex)
|
||||
with open(path, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
log("manifest written to %s" % path)
|
||||
|
||||
|
||||
def main():
|
||||
sex, out_dir, lods_blend_out = parse_args()
|
||||
log("sex=%s out_dir=%s lods_blend=%s" % (sex, out_dir,
|
||||
lods_blend_out))
|
||||
|
||||
cleanup_previous(sex)
|
||||
|
||||
armature = find_armature(sex)
|
||||
head_bone = find_head_bone(armature)
|
||||
log("shared armature '%s', head bone '%s'"
|
||||
% (armature.name, head_bone))
|
||||
|
||||
catalog = collect_catalog_objects()
|
||||
log("catalog objects (%d): %s"
|
||||
% (len(catalog), [ob.name for ob in catalog]))
|
||||
|
||||
log("=== step 1: atlas ===")
|
||||
atlas, rects = build_atlas(catalog, sex, out_dir)
|
||||
atlas_mat = make_atlas_material(sex, atlas)
|
||||
|
||||
log("=== step 2: LOD1 variants ===")
|
||||
lod1 = generate_lod1(catalog, rects, atlas_mat, sex)
|
||||
log("generated %d LOD1 objects" % len(lod1))
|
||||
|
||||
log("=== step 3: LOD2 impostors ===")
|
||||
lod2 = generate_lod2(catalog, rects, atlas_mat, armature,
|
||||
head_bone, sex)
|
||||
|
||||
log("=== step 4: LOD3 impostors ===")
|
||||
generate_lod3(lod2, armature, sex)
|
||||
|
||||
log("=== step 5: manifest + save ===")
|
||||
write_manifest(sex, out_dir)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=lods_blend_out)
|
||||
log("saved lods blend to %s" % lods_blend_out)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user