Create review-only magnetic knight body and equipment models
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
"""Author an unadopted magnetic knight candidate; Blender owns all mesh generation.
|
||||
|
||||
Blender --background --python tools/models/build_hero.py -- --review-dir PATH
|
||||
Blender --background --python tools/models/build_hero.py -- --verify-only
|
||||
Existing game assets, sprites, prefabs and import settings are read only.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / 'SourceAssets/Models/Heroes'
|
||||
EXPORT = ROOT / 'Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Heroes'
|
||||
ATLAS = ROOT / 'SourceAssets/Art/Faces/face-expressions-v2.png'
|
||||
RUNTIME_ATLAS = ROOT / 'Assets/MagnetismHeroes/UnityRuntime/Presentation/Textures/FaceExpressions.png'
|
||||
BLEND = SOURCE / 'MagneticKnight.blend'
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--review-dir', type=Path)
|
||||
parser.add_argument('--verify-only', action='store_true')
|
||||
args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else [])
|
||||
|
||||
|
||||
def sha(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def srgb(value):
|
||||
return value / 12.92 if value <= .04045 else ((value + .055) / 1.055) ** 2.4
|
||||
|
||||
|
||||
def material(name, color, roughness=.45, metallic=0):
|
||||
mat = bpy.data.materials.new(name)
|
||||
mat.diffuse_color = (*[srgb(c) for c in color], 1)
|
||||
mat.use_nodes = True
|
||||
node = mat.node_tree.nodes.get('Principled BSDF')
|
||||
node.inputs['Base Color'].default_value = mat.diffuse_color
|
||||
node.inputs['Roughness'].default_value = roughness
|
||||
node.inputs['Metallic'].default_value = metallic
|
||||
mat['base_color_srgb'] = list(color)
|
||||
mat['portable_roughness'] = roughness
|
||||
mat['portable_metallic'] = metallic
|
||||
return mat
|
||||
|
||||
|
||||
def collection(name):
|
||||
coll = bpy.data.collections.new(name)
|
||||
bpy.context.scene.collection.children.link(coll)
|
||||
return coll
|
||||
|
||||
|
||||
def mesh(name, vertices, faces, mat, coll, smooth=True):
|
||||
data = bpy.data.meshes.new(name + '_Mesh')
|
||||
data.from_pydata(vertices, [], faces)
|
||||
data.update()
|
||||
obj = bpy.data.objects.new(name, data)
|
||||
coll.objects.link(obj)
|
||||
data.materials.append(mat)
|
||||
for poly in data.polygons:
|
||||
poly.use_smooth = smooth
|
||||
return obj
|
||||
|
||||
|
||||
def activate(obj):
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
|
||||
|
||||
def bevel(obj, width=.015, segments=3):
|
||||
activate(obj)
|
||||
mod = obj.modifiers.new('Manufactured_soft_edges', 'BEVEL')
|
||||
mod.width = width
|
||||
mod.segments = segments
|
||||
bpy.ops.object.modifier_apply(modifier=mod.name)
|
||||
normal = obj.modifiers.new('Consistent_corner_normals', 'WEIGHTED_NORMAL')
|
||||
normal.keep_sharp = True
|
||||
bpy.ops.object.modifier_apply(modifier=normal.name)
|
||||
|
||||
|
||||
def outline(name, points, thickness, mat, coll, y=0, edge=.01):
|
||||
count = len(points)
|
||||
verts = [(x, y - thickness / 2, z) for x, z in points]
|
||||
verts += [(x, y + thickness / 2, z) for x, z in points]
|
||||
faces = [tuple(range(count - 1, -1, -1)), tuple(range(count, count * 2))]
|
||||
faces += [(i, (i + 1) % count, (i + 1) % count + count, i + count) for i in range(count)]
|
||||
obj = mesh(name, verts, faces, mat, coll, False)
|
||||
bevel(obj, edge)
|
||||
return obj
|
||||
|
||||
|
||||
def tube(name, points, radius, mat, coll, sides=10):
|
||||
verts = []
|
||||
for i, point in enumerate(points):
|
||||
tangent = Vector(points[min(i + 1, len(points) - 1)]) - Vector(points[max(i - 1, 0)])
|
||||
tangent.normalize()
|
||||
normal = tangent.cross(Vector((0, 0, 1)))
|
||||
if normal.length < .01:
|
||||
normal = tangent.cross(Vector((0, 1, 0)))
|
||||
normal.normalize()
|
||||
second = tangent.cross(normal).normalized()
|
||||
for j in range(sides):
|
||||
verts.append(Vector(point) + radius * (normal * math.cos(j * math.tau / sides) + second * math.sin(j * math.tau / sides)))
|
||||
faces = []
|
||||
for i in range(len(points) - 1):
|
||||
for j in range(sides):
|
||||
a = i * sides + j
|
||||
b = i * sides + (j + 1) % sides
|
||||
faces.append((a, b, b + sides, a + sides))
|
||||
faces += [tuple(range(sides - 1, -1, -1)), tuple((len(points) - 1) * sides + j for j in range(sides))]
|
||||
return mesh(name, verts, faces, mat, coll)
|
||||
|
||||
|
||||
def disk(name, center, radius, depth, mat, coll, vertical_scale=1):
|
||||
count = 48
|
||||
profile = [(radius - .009, -depth / 2), (radius, -depth / 2 + .008),
|
||||
(radius, depth / 2 - .008), (radius - .009, depth / 2)]
|
||||
verts = [(center[0], center[1] - depth / 2, center[2])]
|
||||
for r, y in profile:
|
||||
verts += [(center[0] + r * math.cos(i * math.tau / count), center[1] + y,
|
||||
center[2] + r * vertical_scale * math.sin(i * math.tau / count)) for i in range(count)]
|
||||
verts.append((center[0], center[1] + depth / 2, center[2]))
|
||||
faces = [(0, 1 + i, 1 + (i + 1) % count) for i in range(count)]
|
||||
for row in range(3):
|
||||
for i in range(count):
|
||||
a, b = 1 + row * count + i, 1 + row * count + (i + 1) % count
|
||||
faces.append((a, a + count, b + count, b))
|
||||
faces += [(len(verts) - 1, 1 + 3 * count + (i + 1) % count, 1 + 3 * count + i) for i in range(count)]
|
||||
return mesh(name, verts, faces, mat, coll)
|
||||
|
||||
|
||||
def export(name, objects):
|
||||
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.export_scene.fbx(filepath=str(EXPORT / (name + '.fbx')), use_selection=True,
|
||||
object_types={'MESH'}, axis_forward='-Z', axis_up='Y', global_scale=1,
|
||||
apply_unit_scale=True, apply_scale_options='FBX_SCALE_ALL', use_mesh_modifiers=True,
|
||||
bake_anim=False, add_leaf_bones=False, path_mode='AUTO')
|
||||
|
||||
|
||||
def aim(obj, point):
|
||||
obj.rotation_euler = (Vector(point) - obj.location).to_track_quat('-Z', 'Y').to_euler()
|
||||
|
||||
|
||||
def camera(location, target, size=1.95):
|
||||
cam = bpy.data.objects.get('ReviewCamera')
|
||||
if cam is None:
|
||||
data = bpy.data.cameras.new('ReviewCamera')
|
||||
cam = bpy.data.objects.new('ReviewCamera', data)
|
||||
review.objects.link(cam)
|
||||
# Keep every orthographic ray origin above the floor, including low front views.
|
||||
cam.location = Vector(target) + (Vector(location) - Vector(target)) * 4
|
||||
aim(cam, target)
|
||||
cam.data.type = 'ORTHO'
|
||||
cam.data.ortho_scale = size
|
||||
bpy.context.scene.camera = cam
|
||||
|
||||
|
||||
def read_sprite(index):
|
||||
path = ROOT / f'Assets/MagnetismHeroes/UnityRuntime/Presentation/Sprites/Face{index}.asset'
|
||||
text = path.read_text(encoding='utf-8')
|
||||
rect = re.search(r'm_Rect:\s*serializedVersion: \d+\s*x: ([\d.]+)\s*y: ([\d.]+)\s*width: ([\d.]+)\s*height: ([\d.]+)', text)
|
||||
pivot = re.search(r'm_Pivot: \{x: ([\d.]+), y: ([\d.]+)\}', text)
|
||||
return {'name': re.search(r'm_Name: (.+)', text).group(1), 'rect': list(map(float, rect.groups())), 'pivot': list(map(float, pivot.groups())), 'path': path.relative_to(ROOT).as_posix(), 'sha256': sha(path)}
|
||||
|
||||
|
||||
def expression(index):
|
||||
# Unity nPOTScale=1 imports the 1536-wide PNG at width 2048.
|
||||
# Existing Sprite rects are in imported pixels; use their normalized UVs.
|
||||
record = sprite_records[index]
|
||||
x, y, width, height = record['rect']
|
||||
px, py = record['pivot']
|
||||
for item, uv in zip(face.data.uv_layers.active.data, face_base_uv):
|
||||
item.uv = ((x + width * uv[0]) / 2048, (y + height * uv[1]) / 1024)
|
||||
# Maintain bottom-row .68 sprite pivot in the displayed plane.
|
||||
for vertex, original in zip(face.data.vertices, face_base_vertices):
|
||||
vertex.co = (original[0], original[1], original[2] + (.5 - py) * .48)
|
||||
|
||||
|
||||
def validate_source():
|
||||
body = bpy.data.objects['HeroBody']
|
||||
assert max(abs(v.co.length - .5) for v in body.data.vertices) < 1e-6, 'Body radius changed'
|
||||
assert tuple(body.location) == (0, 0, 0)
|
||||
all_mesh = [o for c in ('01_Body', '02_KnightEquipment') for o in bpy.data.collections[c].objects if o.type == 'MESH']
|
||||
for obj in all_mesh:
|
||||
assert all(abs(s - 1) < 1e-6 for s in obj.scale), obj.name
|
||||
assert len(obj.data.materials) > 0
|
||||
assert len(obj.modifiers) == 0
|
||||
assert all(math.isfinite(v) for vertex in obj.data.vertices for v in vertex.co)
|
||||
assert all(len(p.vertices) >= 3 for p in obj.data.polygons)
|
||||
triangles = sum(sum(len(p.vertices) - 2 for p in o.data.polygons) for o in all_mesh)
|
||||
assert triangles <= 18000, triangles
|
||||
assert not any(o.type == 'ARMATURE' or o.animation_data for o in all_mesh)
|
||||
assert sha(ATLAS) == sha(RUNTIME_ATLAS), 'Atlas copies differ'
|
||||
print(f'ASSERTIONS_OK body_radius=.5 source_meshes={len(all_mesh)} triangles={triangles} root_scale=1 no_animation no_colliders')
|
||||
return {'meshCount': len(all_mesh), 'triangles': triangles, 'bodyRadiusMeters': .5, 'sourceBounds': {o.name: [[min(v.co[i] for v in o.data.vertices), max(v.co[i] for v in o.data.vertices)] for i in range(3)] for o in all_mesh}}
|
||||
|
||||
|
||||
def file_record(path):
|
||||
return {'path': path.relative_to(ROOT).as_posix(), 'bytes': path.stat().st_size, 'sha256': sha(path)}
|
||||
|
||||
|
||||
if args.verify_only:
|
||||
bpy.ops.wm.open_mainfile(filepath=str(BLEND))
|
||||
results = validate_source()
|
||||
export_checks = []
|
||||
for filename in ('HeroBody.fbx', 'KnightEquipment.fbx'):
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
bpy.ops.import_scene.fbx(filepath=str(EXPORT / filename))
|
||||
objects = [o for o in bpy.context.scene.objects if o.type == 'MESH']
|
||||
assert objects, filename
|
||||
assert all(abs(s - 1) < 1e-5 for o in objects for s in o.scale)
|
||||
assert all(len(o.data.polygons) > 0 for o in objects)
|
||||
if filename == 'HeroBody.fbx':
|
||||
obj = objects[0]
|
||||
assert max(abs((obj.matrix_world @ v.co).length - .5) for v in obj.data.vertices) < 1e-5
|
||||
export_checks.append({'file': filename, 'meshCount': len(objects), 'nonEmpty': True, 'scaleOne': True})
|
||||
report = {'tool': bpy.app.version_string, 'sourceReopened': True, 'sourceAssertions': results, 'fbxReimportChecks': export_checks, 'files': [file_record(BLEND)] + [file_record(EXPORT / n) for n in ('HeroBody.fbx', 'KnightEquipment.fbx')]}
|
||||
(SOURCE / 'verification.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
|
||||
print('REOPEN_AND_FBX_REIMPORT_OK')
|
||||
sys.exit(0)
|
||||
|
||||
SOURCE.mkdir(parents=True, exist_ok=True)
|
||||
EXPORT.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
for coll in list(bpy.data.collections):
|
||||
bpy.data.collections.remove(coll)
|
||||
body_coll = collection('01_Body')
|
||||
gear = collection('02_KnightEquipment')
|
||||
face_coll = collection('03_ExistingFace_ReviewOnly')
|
||||
ribbon_coll = collection('04_Ribbon_ReviewOnly')
|
||||
review = collection('90_ReviewStudio_NotExported')
|
||||
cyan = material('Hero_CyanSatin', (.17, .72, .83), .48)
|
||||
silver = material('Helmet_SilverTint', (.72, .85, .86), .39, .18)
|
||||
cream = material('Ceramic_WarmCream', (.97, .92, .78), .43)
|
||||
gold = material('Trim_HoneyGold', (.94, .71, .34), .42, .12)
|
||||
teal = material('Enamel_DeepTeal', (.055, .40, .44), .42)
|
||||
blade_mat = material('Blade_SatinSilver', (.85, .92, .91), .4, .2)
|
||||
|
||||
# Seamless latitude mesh, welded poles and smooth normals; fixed diameter one meter.
|
||||
verts = [(0, 0, .5)]
|
||||
segments, rings = 64, 32
|
||||
for row in range(1, rings):
|
||||
latitude = math.pi * row / rings
|
||||
for col in range(segments):
|
||||
longitude = math.tau * col / segments
|
||||
verts.append((.5 * math.sin(latitude) * math.cos(longitude), .5 * math.sin(latitude) * math.sin(longitude), .5 * math.cos(latitude)))
|
||||
verts.append((0, 0, -.5))
|
||||
faces = [(0, 1 + col, 1 + (col + 1) % segments) for col in range(segments)]
|
||||
for row in range(rings - 2):
|
||||
for col in range(segments):
|
||||
a = 1 + row * segments + col
|
||||
b = 1 + row * segments + (col + 1) % segments
|
||||
faces.append((a, a + segments, b + segments, b))
|
||||
faces += [(len(verts) - 1, 1 + (rings - 2) * segments + (col + 1) % segments, 1 + (rings - 2) * segments + col) for col in range(segments)]
|
||||
body = mesh('HeroBody', verts, faces, cyan, body_coll)
|
||||
|
||||
# Open rounded helmet: raised front brow, protective sides/back, continuous rolled edge.
|
||||
verts, faces, rim = [(0, 0, .538)], [], []
|
||||
steps = 14
|
||||
for row in range(1, steps + 1):
|
||||
for col in range(64):
|
||||
phi = col * math.tau / 64
|
||||
front = max(0, -math.sin(phi))
|
||||
bottom_z = -.07 + .405 * front ** 4
|
||||
theta = math.acos(bottom_z / .532) * row / steps
|
||||
point = (.532 * math.sin(theta) * math.cos(phi), .532 * math.sin(theta) * math.sin(phi), .538 * math.cos(theta))
|
||||
verts.append(point)
|
||||
if row == steps:
|
||||
rim.append(point)
|
||||
for col in range(64):
|
||||
faces.append((0, 1 + col, 1 + (col + 1) % 64))
|
||||
for row in range(steps - 1):
|
||||
for col in range(64):
|
||||
a, b = 1 + row * 64 + col, 1 + row * 64 + (col + 1) % 64
|
||||
faces.append((a, a + 64, b + 64, b))
|
||||
helmet = mesh('Helmet_OpenShell', verts, faces, silver, gear)
|
||||
activate(helmet)
|
||||
solid = helmet.modifiers.new('RealShellThickness', 'SOLIDIFY')
|
||||
solid.thickness = .018
|
||||
solid.offset = -1
|
||||
bpy.ops.object.modifier_apply(modifier=solid.name)
|
||||
tube('Helmet_RolledCreamRim', rim + [rim[0]], .018, cream, gear)
|
||||
# Low crown ridge tracks the shell rather than becoming a tall plume.
|
||||
ridge_points = [(0, -.43 + i * .86 / 28, math.sqrt(max(0, .54 ** 2 - (-.43 + i * .86 / 28) ** 2)) + .018) for i in range(29)]
|
||||
tube('Helmet_CrownGoldRail', ridge_points, .028, gold, gear, 12)
|
||||
tube('Helmet_CrownTealInlay', [(x, y, z + .019) for x, y, z in ridge_points], .014, teal, gear)
|
||||
for sign in (-1, 1):
|
||||
# Small recessed hinge / fastening details only, no arms or secondary heads.
|
||||
disk(f'Helmet_Hinge_{sign}', (sign * .465, -.24, .035), .042, .024, gold, gear)
|
||||
disk(f'Helmet_HingeInset_{sign}', (sign * .465, -.257, .035), .022, .013, teal, gear)
|
||||
|
||||
# Rounded short toy blade with a shaped point, recessed full-length center inlay.
|
||||
sword = []
|
||||
sword.append(outline('Sword_SoftBlade', [(-.66, -.015), (-.72, .39), (-.705, .445), (-.67, .478), (-.636, .445), (-.618, .39), (-.615, -.015)], .055, blade_mat, gear, -.105, .013))
|
||||
sword.append(outline('Sword_CenterInlay', [(-.676, .035), (-.692, .383), (-.67, .432), (-.65, .383), (-.649, .035)], .008, cream, gear, -.139, .005))
|
||||
sword.append(outline('Sword_SoftCrossguard', [(-.79, -.036), (-.785, .008), (-.54, .008), (-.535, -.036), (-.565, -.06), (-.76, -.06)], .085, gold, gear, -.105, .018))
|
||||
sword.append(outline('Sword_TealGrip', [(-.705, -.195), (-.697, -.04), (-.633, -.04), (-.625, -.195)], .075, teal, gear, -.105, .015))
|
||||
sword.append(disk('Sword_CreamPommel', (-.665, -.105, -.205), .05, .075, cream, gear))
|
||||
for z in (-.08, -.13, -.18):
|
||||
sword.append(outline('Sword_GripBand' + str(z), [(-.704, z - .006), (-.704, z + .006), (-.631, z + .006), (-.631, z - .006)], .079, gold, gear, -.106, .004))
|
||||
|
||||
# Convex layered circular shield with an unmistakable horseshoe magnet emblem.
|
||||
disk('Shield_CreamRolledEdge', (.567, -.17, -.13), .228, .07, cream, gear, 1.06)
|
||||
disk('Shield_GoldInsetRing', (.567, -.213, -.13), .202, .029, gold, gear, 1.06)
|
||||
disk('Shield_TealFace', (.567, -.233, -.13), .18, .023, teal, gear, 1.06)
|
||||
magnet_points = []
|
||||
for i in range(25):
|
||||
angle = math.pi + math.pi * i / 24
|
||||
magnet_points.append((.567 + .080 * math.cos(angle), -.262, -.104 + .080 * math.sin(angle)))
|
||||
magnet_points = [(.487, -.262, -.058)] + magnet_points + [(.647, -.262, -.058)]
|
||||
tube('Shield_MagnetEmblem', magnet_points, .021, cream, gear, 12)
|
||||
for x in (.487, .647):
|
||||
outline('Shield_MagnetPole_' + str(x), [(x - .024, -.075), (x + .024, -.075), (x + .024, -.038), (x - .024, -.038)], .035, gold, gear, -.263, .006)
|
||||
|
||||
# Atlas is packed unchanged; rendered face is review-only, game retains its sprites.
|
||||
sprite_records = [read_sprite(i) for i in range(6)]
|
||||
face_mat = material('ExistingAtlas_AlphaDecal_REVIEW_ONLY', (1, 1, 1), .65)
|
||||
nodes = face_mat.node_tree.nodes
|
||||
node = nodes.get('Principled BSDF')
|
||||
image = bpy.data.images.load(str(ATLAS), check_existing=True)
|
||||
image.pack()
|
||||
texture = nodes.new('ShaderNodeTexImage')
|
||||
texture.image = image
|
||||
texture.interpolation = 'Linear'
|
||||
face_mat.node_tree.links.new(texture.outputs['Color'], node.inputs['Base Color'])
|
||||
face_mat.node_tree.links.new(texture.outputs['Alpha'], node.inputs['Alpha'])
|
||||
face_mat.surface_render_method = 'DITHERED'
|
||||
face = mesh('ExistingExpression_ReviewOnly', [(-.39, -.505, -.26), (.39, -.505, -.26), (.39, -.505, .22), (-.39, -.505, .22)], [(0, 1, 2, 3)], face_mat, face_coll, False)
|
||||
uv = face.data.uv_layers.new(name='ExistingSpriteRect')
|
||||
face_base_uv = [(0, 0), (1, 0), (1, 1), (0, 1)]
|
||||
face_base_vertices = [tuple(v.co) for v in face.data.vertices]
|
||||
expression(0)
|
||||
|
||||
# Review illustration of the existing light ribbon's thin taper, not a replacement cape.
|
||||
for side in (-1, 1):
|
||||
points = [(side * (.1 + i * .03), .41 + i * .025, .12 - i * .028 + .02 * math.sin(i * .5)) for i in range(13)]
|
||||
verts = [(x + offset, y, z) for i, (x, y, z) in enumerate(points) for offset in (-.038 * (1 - i / 18), .038 * (1 - i / 18))]
|
||||
faces = [(i * 2, i * 2 + 1, i * 2 + 3, i * 2 + 2) for i in range(12)]
|
||||
mesh('Ribbon_Reference_' + str(side), verts, faces, teal, ribbon_coll)
|
||||
|
||||
scene = bpy.context.scene
|
||||
scene.unit_settings.system = 'METRIC'
|
||||
scene.unit_settings.scale_length = 1
|
||||
scene.render.engine = 'CYCLES'
|
||||
prefs = bpy.context.preferences.addons['cycles'].preferences
|
||||
prefs.compute_device_type = 'OPTIX'
|
||||
prefs.get_devices()
|
||||
for device in prefs.devices:
|
||||
device.use = device.type == 'OPTIX'
|
||||
assert any(d.use for d in prefs.devices), 'Expected authorized OPTIX renderer unavailable'
|
||||
scene.cycles.device = 'GPU'
|
||||
scene.cycles.samples = 48
|
||||
scene.cycles.use_denoising = True
|
||||
scene.render.resolution_x = 1000
|
||||
scene.render.resolution_y = 1000
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = 'PNG'
|
||||
scene.view_settings.view_transform = 'AgX'
|
||||
scene.world.use_nodes = True
|
||||
scene.world.node_tree.nodes.get('Background').inputs[0].default_value = (.72, .84, .85, 1)
|
||||
scene.world.node_tree.nodes.get('Background').inputs[1].default_value = .35
|
||||
floor_mat = material('Review_Backdrop_Mint', (.84, .91, .87), .8)
|
||||
mesh('ReviewGround', [(-200, -200, -.52), (200, -200, -.52), (200, 200, -.52), (-200, 200, -.52)], [(0, 1, 2, 3)], floor_mat, review, False)
|
||||
for name, location, energy, size, color in [
|
||||
('SoftKey', (-3, -4, 5), 400, 4, (1, .87, .72)),
|
||||
('SkyFill', (4, -2, 2), 230, 3.5, (.67, .86, 1)),
|
||||
('WarmRim', (1, 3, 4), 450, 3, (1, .93, .76))]:
|
||||
data = bpy.data.lights.new(name, 'AREA')
|
||||
data.energy, data.shape, data.size, data.color = energy, 'DISK', size, color
|
||||
obj = bpy.data.objects.new(name, data)
|
||||
review.objects.link(obj)
|
||||
obj.location = location
|
||||
aim(obj, (0, 0, 0))
|
||||
camera((1.5, -3, 1.35), (0, 0, .01))
|
||||
validate_source()
|
||||
export('HeroBody', list(body_coll.objects))
|
||||
export('KnightEquipment', list(gear.objects))
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(BLEND))
|
||||
|
||||
if args.review_dir:
|
||||
args.review_dir.mkdir(parents=True, exist_ok=True)
|
||||
shots = [('hero_three_quarter', (1.6, -3.2, 1.45)), ('knight_front', (0, -4, .3)),
|
||||
('knight_side', (4, 0, .35)), ('knight_back', (0, 4, .3))]
|
||||
for name, position in shots:
|
||||
camera(position, (0, 0, .02), 1.95)
|
||||
scene.render.filepath = str(args.review_dir / (name + '.png'))
|
||||
bpy.ops.render.render(write_still=True)
|
||||
for obj in list(gear.objects) + list(face_coll.objects) + list(ribbon_coll.objects):
|
||||
obj.hide_render = True
|
||||
camera((1.3, -3.2, 1), (0, 0, 0), 1.5)
|
||||
scene.render.filepath = str(args.review_dir / 'body_standalone.png')
|
||||
bpy.ops.render.render(write_still=True)
|
||||
for obj in list(gear.objects) + list(face_coll.objects) + list(ribbon_coll.objects):
|
||||
obj.hide_render = False
|
||||
camera((0, -4, .15), (0, 0, .02), 1.95)
|
||||
scene.render.resolution_x = scene.render.resolution_y = 800
|
||||
for i in range(6):
|
||||
expression(i)
|
||||
scene.render.filepath = str(args.review_dir / f'expression_{i}_{sprite_records[i]["name"].strip()}.png')
|
||||
bpy.ops.render.render(write_still=True)
|
||||
expression(0)
|
||||
camera((1.5, -3, 1.35), (0, 0, .01))
|
||||
scene.render.resolution_x = scene.render.resolution_y = 1000
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(BLEND))
|
||||
|
||||
manifest = {
|
||||
'schemaVersion': 1, 'status': 'CANDIDATE_ONLY_USER_MODEL_REVIEW_REQUIRED',
|
||||
'authoring': 'Project-original meshes made by tools/models/build_hero.py; no downloaded character geometry.',
|
||||
'license': 'Original project-authored mesh/material work; existing generated face atlas retains SourceAssets/Art/Faces/provenance.json usage/service terms. No exclusive rights claim for generated decal.',
|
||||
'tool': {'name': 'Blender', 'actualVersion': bpy.app.version_string},
|
||||
'units': 'meters; authoring Z up, body-centered pivot at origin; .5m body radius; scale applied, object scales 1',
|
||||
'export': {'axisForward': '-Z', 'axisUp': 'Y', 'globalScale': 1, 'applyUnitScale': True, 'applyScaleOptions': 'FBX_SCALE_ALL', 'bakeAnim': False, 'selection': 'Body separately; knight equipment assembly of named meshes. Review face/ribbon/studio excluded.'},
|
||||
'attachments': {'Helmet': 'Upper sphere shell with open front, eye/mouth clearance', 'Sword': 'Body-left, short blunt toy blade; no arm, no collider', 'Shield': 'Body-right round layered shield; magnet emblem', 'pivot': 'Every exported mesh local origin is the same body center. Preserve imported axis transform in future wrapper.'},
|
||||
'portableMaterials': [{'name': m.name, 'baseColorSrgb': list(m['base_color_srgb']), 'roughness': m['portable_roughness'], 'metallic': m['portable_metallic']} for m in (cyan, silver, cream, gold, teal, blade_mat)],
|
||||
'existingFace': {'source': file_record(ATLAS), 'runtime': file_record(RUNTIME_ATLAS), 'spriteRecords': sprite_records,
|
||||
'reviewNormalization': 'Read existing rects and pivots. TextureImporter nPOTScale=1 imports original 1536x1024 PNG at 2048x1024. Review UV uses existing imported-grid x/2048,width/2048,y/1024,height/1024; pivot preserved. Existing files unchanged. Game face appearance/import still requires later review.'},
|
||||
'files': [file_record(BLEND)] + [file_record(EXPORT / name) for name in ('HeroBody.fbx', 'KnightEquipment.fbx')],
|
||||
'reviewScope': 'Actual Blender renders and source/export geometry assertions. No Unity import, no Android, no gameplay/selection/UI-clearance pass claimed. Candidate files have no hand-authored .meta; future Unity import owner must generate them after approval.',
|
||||
'renders': str(args.review_dir) if args.review_dir else None,
|
||||
}
|
||||
(SOURCE / 'provenance.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8')
|
||||
print('CANDIDATE_AUTHORING_COMPLETE')
|
||||
Reference in New Issue
Block a user