Phase 6.5 checkpoint

This commit is contained in:
2026-10-26 11:03:11 +03:00
parent 9e2d9fce59
commit adc4c91a2c
61 changed files with 10261 additions and 450 deletions
@@ -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
```
+32
View File
@@ -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()
+657
View File
@@ -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()