SETUP 08: ASSET PIPELINE
Полный пайплайн управления ассетами: организация, версионирование, валидация, экспорт, деплой
⚙️ Сетапы ⭐⭐⭐ полная 10 блоков кода обновлено 2026-11-12
ID: SETUP-08 | Type: Asset Management & Pipeline | Difficulty: ⭐⭐⭐ | Time: 40-60 min
🎯 ЦЕЛЬ
Полный пайплайн управления ассетами: организация, версионирование, валидация, экспорт, деплой
📋 АРХИТЕКТУРА ПАЙПЛАЙНА
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ CREATE │───▶│ VALIDATE │───▶│ VERSION │───▶│ EXPORT │
│ (Model/ │ │ (Auto QA) │ │ (Git LFS) │ │ (Multi-fmt)│
│ Texture) │ │ │ │ │ │ │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ ASSET REGISTRY (JSON/DB) │
│ Metadata | Dependencies | Tags | LODs | Preview │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ DEPLOY TARGETS │
│ Game Engine | Web/Three.js | USD Pipeline | Render Farm │
└─────────────────────────────────────────────────────────────────┘
📁 СТРУКТУРА АССЕТОВ
assets/
├── models/
│ ├── characters/
│ │ ├── hero/
│ │ │ ├── v001/
│ │ │ │ ├── hero_v001.blend
│ │ │ │ ├── hero_v001.fbx
│ │ │ │ ├── hero_v001.gltf
│ │ │ │ ├── hero_v001.usdz
│ │ │ │ ├── textures/
│ │ │ │ │ ├── hero_basecolor_2k.png
│ │ │ │ │ ├── hero_roughness_2k.png
│ │ │ │ │ ├── hero_normal_2k.exr
│ │ │ │ │ └── hero_metallic_2k.png
│ │ │ │ ├── lods/
│ │ │ │ │ ├── hero_LOD0.blend
│ │ │ │ │ ├── hero_LOD1.blend
│ │ │ │ │ └── hero_LOD2.blend
│ │ │ │ ├── metadata.json
│ │ │ │ └── preview.png
│ │ │ └── latest -> v001/
│ │ └── ...
│ ├── props/
│ ├── environment/
│ └── vehicles/
├── materials/
│ ├── library/
│ │ ├── metal/
│ │ ├── wood/
│ │ ├── fabric/
│ │ └── ...
│ └── substance/
├── textures/
│ ├── pbr/
│ ├── hdri/
│ └── masks/
├── hdri/
├── scripts/
│ ├── validators/
│ ├── exporters/
│ └── pipeline/
├── registry/
│ ├── assets.json # Главный реестр
│ ├── index.db # SQLite для поиска
│ └── tags.yaml # Теговые категории
└── pipeline/
├── config.yaml
├── validators.yaml
└── export_presets.yaml
📄 МЕТАДАННЫЕ АССЕТА (metadata.json)
{
"asset": {
"id": "hero_main",
"name": "Hero Character",
"type": "character",
"category": "characters",
"version": "v001",
"status": "approved", // "wip", "review", "approved", "deprecated"
"created": "2026-01-15T10:30:00Z",
"updated": "2026-01-15T14:22:00Z",
"author": "artist_name",
"tags": ["hero", "human", "rigged", "game-ready", "lod"],
"description": "Main protagonist character with full rig"
},
"technical": {
"blender_version": "4.2.3",
"triangles": 24500,
"vertices": 12800,
"uv_tiles": 2,
"materials_count": 4,
"bones_count": 150,
"shape_keys": 28,
"uv_maps": ["UVMap", "Lightmap"],
"vertex_groups": 45
},
"lod": {
"levels": 3,
"LOD0": {"tris": 24500, "distance": 0, "file": "hero_LOD0.blend"},
"LOD1": {"tris": 8200, "distance": 15, "file": "hero_LOD1.blend"},
"LOD2": {"tris": 2100, "distance": 50, "file": "hero_LOD2.blend"}
},
"materials": [
{"name": "Mat_Skin", "type": "skin", "textures": ["basecolor", "roughness", "normal", "subsurface"]},
{"name": "Mat_Hair", "type": "hair", "textures": ["basecolor", "roughness", "normal", "alpha"]},
{"name": "Mat_Clothing", "type": "fabric", "textures": ["basecolor", "roughness", "normal", "height"]},
{"name": "Mat_Eyes", "type": "eye", "textures": ["basecolor", "normal", "emission"]}
],
"rig": {
"type": "rigify",
"bones": 150,
"face_bones": 52,
"ik_fk": true,
"twist_bones": true,
"corrective_shapes": 24
},
"exports": {
"gltf": {"file": "hero_v001.gltf", "size_mb": 12.4, "version": "2.0"},
"fbx": {"file": "hero_v001.fbx", "size_mb": 15.2, "version": "7.4"},
"usdz": {"file": "hero_v001.usdz", "size_mb": 18.7, "version": "1.0"},
"usd": {"file": "hero_v001.usd", "size_mb": 22.1, "purpose": "default"}
},
"preview": {
"thumbnail": "preview.png",
"turntable": "turntable.mp4",
"wireframe": "wireframe.png"
},
"dependencies": [
"assets/materials/library/skin_base.mat",
"assets/materials/library/hair_shader.mat"
],
"pipeline": {
"validated": true,
"validator_version": "1.2.0",
"last_validation": "2026-01-15T14:22:00Z",
"warnings": [],
"errors": []
}
}
✅ АВТОВАЛИДАЦИЯ (QA)
validators/geometry_validator.py
import bpy
import bmesh
from typing import List, Dict, Any
class GeometryValidator:
"""Валидация геометрии ассета"""
def __init__(self, obj: bpy.types.Object):
self.obj = obj
self.errors = []
self.warnings = []
self.info = []
def validate_all(self) -> Dict[str, Any]:
self.check_non_manifold()
self.check_loose_geometry()
self.check_uv_overlaps()
self.check_vertex_groups()
self.check_face_orientation()
self.check_sharp_edges()
self.check_triangulation()
self.check_scale()
return {
"valid": len(self.errors) == 0,
"errors": self.errors,
"warnings": self.warnings,
"info": self.info,
"stats": self.get_stats()
}
def check_non_manifold(self):
bm = bmesh.new()
bm.from_mesh(self.obj.data)
non_manifold = [e for e in bm.edges if not e.is_manifold]
if non_manifold:
self.errors.append(f"Non-manifold edges: {len(non_manifold)}")
bm.free()
def check_uv_overlaps(self):
if not self.obj.data.uv_layers:
self.warnings.append("No UV maps found")
return
bm = bmesh.new()
bm.from_mesh(self.obj.data)
uv_layer = bm.loops.layers.uv.active
# Упрощенная проверка
uv_coords = []
for face in bm.faces:
for loop in face.loops:
uv = loop[uv_layer].uv
uv_coords.append((round(uv.x, 4), round(uv.y, 4)))
if len(uv_coords) != len(set(uv_coords)):
self.warnings.append("Possible UV overlaps detected")
bm.free()
def check_scale(self):
scale = self.obj.scale
if any(abs(s - 1.0) > 0.001 for s in scale):
self.warnings.append(f"Object scale not applied: {scale}")
def get_stats(self) -> Dict:
mesh = self.obj.data
return {
"vertices": len(mesh.vertices),
"edges": len(mesh.edges),
"faces": len(mesh.polygons),
"triangles": sum(len(p.vertices) - 2 for p in mesh.polygons),
"materials": len(mesh.materials),
"uv_maps": len(mesh.uv_layers),
"vertex_groups": len(self.obj.vertex_groups),
"shape_keys": len(self.obj.data.shape_keys.key_blocks) if self.obj.data.shape_keys else 0,
}
# CLI usage
def validate_asset(blend_path: str) -> Dict:
import bpy
bpy.ops.wm.open_mainfile(filepath=blend_path)
results = {}
for obj in bpy.data.objects:
if obj.type == 'MESH':
validator = GeometryValidator(obj)
results[obj.name] = validator.validate_all()
return results
validators/material_validator.py
class MaterialValidator:
"""Валидация PBR материалов"""
REQUIRED_PBR_MAPS = ["base_color", "roughness", "normal", "metallic"]
OPTIONAL_MAPS = ["height", "ao", "emission", "subsurface"]
def validate_material(self, mat: bpy.types.Material) -> Dict:
errors = []
warnings = []
if not mat.use_nodes:
errors.append("Material doesn't use nodes")
return {"valid": False, "errors": errors, "warnings": warnings}
nodes = mat.node_tree.nodes
principled = None
for node in nodes:
if node.type == 'BSDF_PRINCIPLED':
principled = node
break
if not principled:
errors.append("No Principled BSDF found")
return {"valid": False, "errors": errors, "warnings": warnings}
# Проверка текстур
for map_name in self.REQUIRED_PBR_MAPS:
input_socket = principled.inputs.get(map_name.title().replace('_', ' '))
if input_socket and not input_socket.links:
warnings.append(f"Missing {map_name} texture")
# Проверка цветовых пространств
for node in nodes:
if node.type == 'TEX_IMAGE' and node.image:
if node.image.colorspace_settings.name == 'sRGB' and 'roughness' in node.label.lower():
warnings.append(f"Roughness map '{node.image.name}' uses sRGB instead of Non-Color")
if node.image.colorspace_settings.name == 'sRGB' and 'normal' in node.label.lower():
warnings.append(f"Normal map '{node.image.name}' uses sRGB instead of Non-Color")
return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}
📦 ЭКСПОРТЕРЫ (МУЛЬТИ-ФОРМАТ)
exporters/multi_exporter.py
import bpy
import os
import json
from pathlib import Path
from typing import List, Dict
class MultiExporter:
"""Экспорт в все необходимые форматы"""
def __init__(self, output_dir: str, asset_id: str, version: str):
self.output_dir = Path(output_dir)
self.asset_id = asset_id
self.version = version
self.results = {}
def export_all(self, objects: List[bpy.types.Object], formats: List[str] = None) -> Dict:
formats = formats or ["GLTF", "FBX", "USDZ", "USD"]
for fmt in formats:
if fmt == "GLTF":
self.results["gltf"] = self.export_gltf(objects)
elif fmt == "FBX":
self.results["fbx"] = self.export_fbx(objects)
elif fmt == "USDZ":
self.results["usdz"] = self.export_usdz(objects)
elif fmt == "USD":
self.results["usd"] = self.export_usd(objects)
return self.results
def export_gltf(self, objects) -> Dict:
self.output_dir.joinpath("gltf").mkdir(parents=True, exist_ok=True)
results = {}
for obj in objects:
filepath = self.output_dir / "gltf" / f"{obj.name}.glb"
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.export_scene.gltf(
filepath=str(filepath),
export_format='GLB',
use_selection=True,
export_apply=True,
export_tangents=True,
export_materials='EXPORT',
export_colors=True,
export_animations=True,
export_morph=True,
export_skins=True,
export_extras=True,
)
size = filepath.stat().st_size
results[obj.name] = {"file": str(filepath), "size_mb": round(size/1024/1024, 2)}
return results
def export_fbx(self, objects) -> Dict:
self.output_dir.joinpath("fbx").mkdir(parents=True, exist_ok=True)
results = {}
for obj in objects:
filepath = self.output_dir / "fbx" / f"{obj.name}.fbx"
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.export_scene.fbx(
filepath=str(filepath),
use_selection=True,
apply_unit_scale=True,
apply_scale_options='FBX_SCALE_ALL',
bake_space_transform=True,
object_types={'ARMATURE', 'MESH'},
use_armature_deform_only=True,
add_leaf_bones=False,
primary_bone_axis='Y_FORWARD',
secondary_bone_axis='X_UP',
)
size = filepath.stat().st_size
results[obj.name] = {"file": str(filepath), "size_mb": round(size/1024/1024, 2)}
return results
def export_usdz(self, objects) -> Dict:
self.output_dir.joinpath("usdz").mkdir(parents=True, exist_ok=True)
results = {}
for obj in objects:
filepath = self.output_dir / "usdz" / f"{obj.name}.usdz"
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.wm.usd_export(
filepath=str(filepath),
export_selected=True,
export_animation=True,
export_materials=True,
)
size = filepath.stat().st_size
results[obj.name] = {"file": str(filepath), "size_mb": round(size/1024/1024, 2)}
return results
def export_usd(self, objects) -> Dict:
self.output_dir.joinpath("usd").mkdir(parents=True, exist_ok=True)
results = {}
filepath = self.output_dir / "usd" / f"{self.asset_id}.usd"
bpy.ops.object.select_all(action='DESELECT')
for obj in objects:
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
bpy.ops.wm.usd_export(
filepath=str(filepath),
export_selected=True,
export_animation=True,
export_materials=True,
export_uvmaps=True,
)
size = filepath.stat().st_size
return {"file": str(filepath), "size_mb": round(size/1024/1024, 2)}
🏷️ АССЕТ РЕЕСТР (REGISTRY)
registry/assets.json
{
"version": "1.0",
"updated": "2026-01-15T15:00:00Z",
"assets": {
"hero_main": {
"path": "assets/models/characters/hero/latest/",
"version": "v001",
"status": "approved",
"tags": ["character", "hero", "rigged", "lod"],
"dependencies": ["skin_material", "hair_shader"],
"exports": ["gltf", "fbx", "usdz", "usd"],
"lod": true,
"rigged": true
},
"prop_crate_wooden": {
"path": "assets/models/props/crate_wooden/latest/",
"version": "v003",
"status": "approved",
"tags": ["prop", "crate", "wood", "game-ready"],
"exports": ["gltf", "fbx"],
"lod": true,
"rigged": false
}
},
"tags": {
"character": ["hero_main", "npc_villager"],
"prop": ["prop_crate_wooden", "prop_barrel"],
"environment": ["building_house_01", "tree_oak"],
"vehicle": ["car_sedan", "truck"],
"game-ready": ["hero_main", "prop_crate_wooden"],
"rigged": ["hero_main"],
"lod": ["hero_main", "prop_crate_wooden"]
}
}
registry/search.py
import json
from pathlib import Path
from typing import List, Dict, Optional
class AssetRegistry:
def __init__(self, registry_path: str):
self.path = Path(registry_path)
self.data = self.load()
def load(self) -> Dict:
if self.path.exists():
return json.loads(self.path.read_text())
return {"version": "1.0", "assets": {}, "tags": {}}
def save(self):
self.path.write_text(json.dumps(self.data, indent=2, ensure_ascii=False))
def register_asset(self, asset_id: str, metadata: Dict):
self.data["assets"][asset_id] = metadata
# Обновить теги
for tag in metadata.get("tags", []):
if tag not in self.data["tags"]:
self.data["tags"][tag] = []
if asset_id not in self.data["tags"][tag]:
self.data["tags"][tag].append(asset_id)
self.save()
def search(self, query: str = None, tags: List[str] = None,
type_filter: str = None, status: str = None) -> List[Dict]:
results = []
for asset_id, asset in self.data["assets"].items():
if query and query.lower() not in asset_id.lower():
continue
if tags and not all(t in asset.get("tags", []) for t in tags):
continue
if type_filter and asset.get("type") != type_filter:
continue
if status and asset.get("status") != status:
continue
results.append({"id": asset_id, **asset})
return results
def get_dependencies(self, asset_id: str) -> List[str]:
return self.data["assets"].get(asset_id, {}).get("dependencies", [])
def get_dependents(self, asset_id: str) -> List[str]:
dependents = []
for aid, asset in self.data["assets"].items():
if asset_id in asset.get("dependencies", []):
dependents.append(aid)
return dependents
🔄 CI/CD ИНТЕГРАЦИЯ
pipeline/validate_and_export.py
#!/usr/bin/env python3
"""
Полный пайплайн: Валидация -> Экспорт -> Реестр -> Деплой
"""
import sys
import argparse
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from scripts.validators.geometry_validator import GeometryValidator
from scripts.validators.material_validator import MaterialValidator
from scripts.exporters.multi_exporter import MultiExporter
from scripts.registry.search import AssetRegistry
def main():
parser = argparse.ArgumentParser(description="Asset Pipeline")
parser.add_argument("asset_path", help="Path to .blend file")
parser.add_argument("--asset-id", required=True, help="Asset ID")
parser.add_argument("--version", required=True, help="Version (e.g., v001)")
parser.add_argument("--output", default="exports/", help="Output directory")
parser.add_argument("--formats", nargs="+", default=["GLTF", "FBX", "USDZ"])
parser.add_argument("--registry", default="registry/assets.json")
parser.add_argument("--skip-validation", action="store_true")
parser.add_argument("--deploy", action="store_true")
args = parser.parse_args()
# 1. Load Blender file
import bpy
bpy.ops.wm.open_mainfile(filepath=args.asset_path)
# 2. Validation
if not args.skip_validation:
print("🔍 Validating geometry...")
for obj in bpy.data.objects:
if obj.type == 'MESH':
validator = GeometryValidator(obj)
result = validator.validate_all()
if not result["valid"]:
print(f"❌ Validation failed for {obj.name}: {result['errors']}")
sys.exit(1)
if result["warnings"]:
print(f"⚠️ Warnings for {obj.name}: {result['warnings']}")
print("🔍 Validating materials...")
for mat in bpy.data.materials:
validator = MaterialValidator()
result = validator.validate_material(mat)
if not result["valid"]:
print(f"❌ Material validation failed: {result['errors']}")
sys.exit(1)
if result["warnings"]:
print(f"⚠️ Material warnings: {result['warnings']}")
print("✅ Validation passed")
# 3. Export
print("📦 Exporting...")
exporter = MultiExporter(args.output, args.asset_id, args.version)
objects = [obj for obj in bpy.data.objects if obj.get("export", True)]
results = exporter.export_all(objects, args.formats)
print(f"✅ Exported: {json.dumps(results, indent=2)}")
# 4. Update Registry
registry = AssetRegistry(args.registry)
# ... обновить реестр с новой версией
# 5. Deploy (если указано)
if args.deploy:
print("🚀 Deploying...")
# aws s3 sync / rsync / etc.
if __name__ == "__main__":
main()
📦 ДЕПЛОЙ ТАРГЕТЫ
deploy/targets.yaml
targets:
unreal_engine:
path: "../UE_Project/Content/Assets/"
formats: ["FBX", "USD"]
naming: "{asset_id}_v{version}"
post_process:
- "python scripts/ue_import_helper.py"
unity:
path: "../Unity_Project/Assets/Imported/"
formats: ["FBX", "GLTF"]
naming: "{asset_id}"
import_settings:
scale_factor: 1.0
generate_colliders: true
godot:
path: "../Godot_Project/assets/models/"
formats: ["GLTF", "GLB"]
naming: "{asset_id}.glb"
threejs_web:
path: "../Web_Project/public/assets/models/"
formats: ["GLTF", "GLB"]
compression: "draco"
texture_compression: "basis"
usd_pipeline:
path: "../USD_Pipeline/assets/"
formats: ["USD", "USDA"]
purpose: "default"
layering: true
render_farm:
path: "/mnt/flamenco-storage/assets/"
formats: ["BLEND", "USD"]
include_lods: true
cdn:
bucket: "s3://my-game-assets/"
formats: ["GLTF", "GLB"]
cdn_url: "https://cdn.mygame.com/assets/"
cache_control: "max-age=31536000"
🔗 СВЯЗИ
- Прочие в категории «Сетапы»: SETUP 14: BLENDER + GODOT PIPELINE, SETUP 38: FAB.COM (EPIC) PIPELINE, SETUP 45: HUNYUAN3D 2.1 LOCAL, SETUP 49: FFMPEG + VSE PIPELINE, SETUP 55: AI UPSCALE PIPELINE
- По темам: SETUP 59: WEB VIEWER EXPORT, CASE 02: GAME PROPS PIPELINE, CASE 03: PRODUCT VISUALIZATION
- Теги: #list, #none, #pipeline, #setup
Связанные материалы
Подобраны по тегам и категории.