Add Blender meadow model candidates for review

This commit is contained in:
NINI
2026-10-08 02:07:12 +09:00
25 changed files with 1790 additions and 0 deletions
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
# 자연 풍경 모델 후보
이 묶음은 사용자 미술 검토를 위한 후보다. 현재 게임의 씬·프리팹·스테이지·공통 재질에 연결하거나 기존 배경을 채택 교체하지 않았다. Unity 메타데이터는 후속 import 담당자가 생성한다.
`MeadowCandidates.blend`에는 바닥 피벗의 독립 메시 21개와 정리된 지형·나무·작은 식생·바위/발견 소품 컬렉션이 있다. `Preview_ClearMeadow`, `Preview_FlowerHills`, `Preview_StoneHills`는 각각 1–10 맑은 초원, 11–20 꽃언덕, 21–30 바위언덕의 조립 검토 장면이다. 내부 테마 식별자는 Garden/Cloud/Workshop로 기록하지만 게임 데이터는 수정하지 않는다. 중앙은 퍼즐 구를 위한 낮은 밀도의 빈 공간이고 주변 지형과 원경이 이어진다.
나무는 휘어진 줄기·드러난 가지·뿌리·곡면 수관·잎을 갖는다. 꽃은 두께 있는 꽃잎·꽃 중심·휘어진 줄기·잎으로 나뉘며, 풀과 고사리는 개별 굽은 잎으로 제작했다. 바위는 큰 면 변화와 부드러운 모서리를 갖는다. 모든 표면은 이름 있는 재질 슬롯과 고정 Base Color/Roughness를 사용한다. Blender 전용 procedural 표면에 의존하지 않으므로 별도 텍스처가 필요하지 않다. 팔레트는 sRGB에서 선형 색으로 변환한 값이며 실제 값은 provenance에 함께 기록한다.
원본은 m·Z-up이며 FBX는 -Z forward/Y-up, 적용된 unit scale, 애니메이션 없음이다. Unity 후속 import는 scale 1·축 변환과 imported root 회전 보존을 확인한다. 가까운 발견 소품은 보이는 형상에 맞는 Collider가 필요하다. `OpenStoneArch`의 열린 구멍을 둘러싸는 BoxCollider로 막거나 앞 hit를 무시하지 않는다. 먼 지형과 작은 식생에는 입력 Collider를 추가하지 않는다. 지형·단차·아치의 실제 배치는 기존 구 초기 위치, 접촉 후보, 회전·확대 범위와 발견 가능성을 별도로 검증해야 한다.
저장소 루트에서 재현한다. 현재 승인된 검토 출력 경로는 호출 시 `--review-dir`로 지정할 수 있다.
```powershell
& 'C:/Program Files/Blender Foundation/Blender 5.2/blender.exe' --background --python-exit-code 1 --python tools/models/build_meadow.py -- --review-dir artifacts/verification/VisualUpgrade/ModelReview/Meadow
& 'C:/Program Files/Blender Foundation/Blender 5.2/blender.exe' --background --python-exit-code 1 --python tools/models/build_meadow.py -- --verify-only --review-dir artifacts/verification/VisualUpgrade/ModelReview/Meadow
```
검토 출력에는 풍경 overview 3장, 소품 catalog 4장, 가지/잎·꽃잎/줄기/잎·아치/단차 closeup 3장, 렌더별 source SHA·scene·frame·camera·GPU·해상도 정보가 들어 있다. 원본 재열기, native 메시의 피벗·UV·재질·상향 지형 법선·삼각형 수, 실제 FBX 재import의 축/크기/재질/UV, 아치 구멍과 기둥의 ray 대조군, 파일 SHA/bytes 검사는 `source-verification.json`에 남긴다. 이 검사는 Unity import, 실제 퍼즐 조작·발견, Android 성능과 사용자 승인 검사를 대체하지 않는다.
자체 제작 원본·도구·내보낸 파일의 실제 SHA/bytes와 Blender 버전/build hash·export 설정·재질 값은 이 디렉터리의 별도 `provenance.json`으로 연결한다. 참고 이미지에서는 청록 하늘과 둥근 자연·파스텔 식생·따뜻한 낮의 방향만 참고했고 캐릭터·무기·UI·화면 구성을 복제하지 않았다.
File diff suppressed because it is too large Load Diff
+756
View File
@@ -0,0 +1,756 @@
"""Author the unadopted Meadow scenery candidate with Blender's native meshes.
Blender --background --python tools/models/build_meadow.py -- --review-dir PATH
Blender --background --python tools/models/build_meadow.py -- --verify-only --review-dir PATH
No existing playground, scene, prefab, material, or stage asset is rewritten.
"""
import argparse
import hashlib
import json
import math
import random
import sys
from pathlib import Path
import bpy
from mathutils import Vector
from mathutils.bvhtree import BVHTree
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / 'SourceAssets/Models/Meadow'
EXPORT = ROOT / 'Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow'
DEFAULT_REVIEW = ROOT / 'artifacts/verification/VisualUpgrade/ModelReview/Meadow'
SETTINGS = dict(use_selection=True, object_types={'MESH'}, axis_forward='-Z', axis_up='Y',
apply_unit_scale=True, apply_scale_options='FBX_SCALE_ALL',
use_mesh_modifiers=True, bake_anim=False, add_leaf_bones=False,
path_mode='AUTO')
ASSETS = {}
MATERIALS = {}
RNG = random.Random(20517)
def material(name, color, roughness=.68):
srgb = color
color = tuple(c / 12.92 if c <= .04045 else ((c + .055) / 1.055) ** 2.4 for c in color)
mat = bpy.data.materials.new(name)
mat.diffuse_color = (*color, 1)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get('Principled BSDF')
bsdf.inputs['Base Color'].default_value = (*color, 1)
bsdf.inputs['Roughness'].default_value = roughness
mat['portableSurface'] = 'constant base color and roughness; no procedural texture dependency'
mat['authoredSRGB'] = list(srgb)
MATERIALS[name] = mat
return mat
def mesh(name, vertices, faces, mats, indices=None, smooth=True):
data = bpy.data.meshes.new(name + 'Mesh')
data.from_pydata(vertices, [], faces)
data.update()
obj = bpy.data.objects.new(name, data)
bpy.context.collection.objects.link(obj)
for mat in mats:
data.materials.append(MATERIALS[mat])
uv = data.uv_layers.new(name='UVMap')
# Dominant-normal box projection: every face has usable, nonzero UV area.
for poly in data.polygons:
poly.use_smooth = smooth
poly.material_index = indices[poly.index] if indices else 0
axis = max(range(3), key=lambda n: abs(poly.normal[n]))
a, b = [n for n in range(3) if n != axis]
for loop_index in poly.loop_indices:
point = data.vertices[data.loops[loop_index].vertex_index].co
uv.data[loop_index].uv = (point[a] * .25, point[b] * .25)
return obj
def tube(name, points, radii, mat='BarkHoney', sides=10):
verts, faces = [], []
for i, p in enumerate(points):
direction = Vector(points[min(i + 1, len(points) - 1)]) - Vector(points[max(0, i - 1)])
direction.normalize()
right = direction.cross(Vector((0, 1, 0))).normalized()
if right.length < .1:
right = direction.cross(Vector((1, 0, 0))).normalized()
up = direction.cross(right).normalized()
for j in range(sides):
a = math.tau * j / sides
q = Vector(p) + radii[i] * (right * math.cos(a) + up * math.sin(a))
verts.append(tuple(q))
faces.extend([tuple(reversed(range(sides))), tuple((len(points) - 1) * sides + j for j in range(sides))])
for i in range(len(points) - 1):
for j in range(sides):
k = i * sides + j
next_k = i * sides + (j + 1) % sides
faces.append((k, next_k, next_k + sides, k + sides))
return mesh(name, verts, faces, [mat])
def leaf(name, start, direction, length, width, mat='LeafLight', bend=.14):
"""Thick curved lance/oval leaf with an explicit raised midrib and tapered tip."""
origin = Vector(start)
forward = Vector(direction).normalized()
side = forward.cross(Vector((0, 0, 1))).normalized()
if side.length < .1:
side = Vector((1, 0, 0))
normal = side.cross(forward).normalized()
verts, faces = [], []
for layer in (0, 1):
for i in range(9):
t = i / 8
half = max(.007, width * math.sin(math.pi * t) ** .7)
center = origin + forward * length * t + normal * (bend * math.sin(math.pi * t) - layer * .022)
for s in (-1, 0, 1):
verts.append(tuple(center + side * s * half + normal * (.025 * (1 - abs(s)) * math.sin(math.pi * t))))
for i in range(8):
for j in range(2):
k = i * 3 + j
faces.append((k, k + 3, k + 4, k + 1))
faces.append((k + 27, k + 28, k + 31, k + 30))
for j in (0, 2):
k = i * 3 + j
faces.append((k, k + 27, k + 30, k + 3) if j == 0 else (k, k + 3, k + 30, k + 27))
faces.extend([(0, 1, 2, 29, 28, 27), (24, 51, 52, 53, 26, 25)])
return mesh(name, verts, [tuple(reversed(f)) for f in faces], [mat])
def organic(name, center, size, mats, seed=0, rock=False, segments=24, rings=12):
"""Authored asymmetric section mesh; foliage lobes and rock planes have different profiles."""
verts, faces, indices = [], [], []
cx, cy, cz = center
sx, sy, sz = size
for k in range(rings + 1):
phi = math.pi * k / rings
ring_radius = max(.015, math.sin(phi))
for j in range(segments):
theta = math.tau * j / segments
lobes = .10 * math.sin(5 * theta + seed + phi * 2) + .06 * math.cos(3 * theta - phi * 4)
shape = 1 + lobes * math.sin(phi)
if rock:
shape += .08 * math.sin(theta * 2 + seed)
z = math.cos(phi)
if rock:
z = math.copysign(abs(z) ** .70, z)
verts.append((cx + sx * ring_radius * math.cos(theta) * shape + .13 * sx * math.sin(phi * 2),
cy + sy * ring_radius * math.sin(theta) * shape,
cz + sz * z * (1 + .035 * math.sin(theta * 3 + seed) * math.sin(phi))))
for k in range(rings):
for j in range(segments):
faces.append((k * segments + j, k * segments + (j + 1) % segments,
(k + 1) * segments + (j + 1) % segments, (k + 1) * segments + j))
# Broad intentional colored planes, rather than isolated checkerboard faces.
band = (j + seed) % segments
accent = (1 if band < segments // 4 and k < rings * .6 else (2 if band > segments * .72 and k > rings * .3 else 0)) if rock else 0
indices.append(min(len(mats) - 1, accent))
faces.extend([tuple(reversed(range(segments))), tuple(rings * segments + j for j in range(segments))])
indices.extend([0, 0])
return mesh(name, verts, [tuple(reversed(f)) for f in faces], mats, indices, smooth=not rock)
def terrain(name, rx, ry, height, hill=False):
verts = [(0, 0, height if hill else .14)]
segments, rings = 64, 16
for k in range(1, rings + 1):
r = k / rings
for j in range(segments):
a = math.tau * j / segments
warp = 1 + .045 * math.sin(3 * a) + .025 * math.sin(7 * a + 1)
z = height * (math.cos(r * math.pi / 2) ** 2) if hill else .14 + .035 * math.sin(a * 3) * r ** 3
verts.append((rx * r * math.cos(a) * warp, ry * r * math.sin(a) * warp, max(.015, z)))
faces, indices = [], []
for j in range(segments):
faces.append((0, 1 + j, 1 + (j + 1) % segments))
indices.append(0)
for k in range(rings - 1):
for j in range(segments):
p = 1 + k * segments + j
q = 1 + k * segments + (j + 1) % segments
faces.append((p, p + segments, q + segments, q))
indices.append(0)
bottom = len(verts)
verts.extend([(verts[1 + (rings - 1) * segments + j][0], verts[1 + (rings - 1) * segments + j][1], 0) for j in range(segments)])
for j in range(segments):
p = 1 + (rings - 1) * segments + j
q = 1 + (rings - 1) * segments + (j + 1) % segments
faces.append((p, bottom + j, bottom + (j + 1) % segments, q))
indices.append(2)
faces.append(tuple(reversed(range(bottom, bottom + segments))))
indices.append(2)
return mesh(name, verts, faces, ['GroundMint', 'GroundSage', 'SoilWarm'], indices)
def finish(name, parts, role, collider='none'):
bpy.ops.object.select_all(action='DESELECT')
for obj in parts:
obj.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
if len(parts) > 1:
bpy.ops.object.join()
obj = bpy.context.object
obj.name = name
# FBX consolidates identical material pointers: make the native slot contract
# equally explicit before export instead of treating consolidation as loss.
old_materials = list(obj.data.materials)
unique_materials = list(dict.fromkeys(old_materials))
remap = [unique_materials.index(m) for m in old_materials]
indices = [remap[p.material_index] for p in obj.data.polygons]
obj.data.materials.clear()
for mat in unique_materials:
obj.data.materials.append(mat)
for polygon, index in zip(obj.data.polygons, indices):
polygon.material_index = index
min_z = min(v.co.z for v in obj.data.vertices)
for vertex in obj.data.vertices:
vertex.co.z -= min_z
bpy.context.scene.cursor.location = (0, 0, 0)
bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
obj['role'] = role
obj['colliderGuidance'] = collider
obj['candidateStatus'] = 'USER_REVIEW_REQUIRED_NOT_ADOPTED'
obj['nativeGeometry'] = 'custom section surfaces, curved tubes, thick curved leaves; no external meshes'
obj.data.update()
ASSETS[name] = obj
return obj
def tree(name, kind):
parts = []
if kind == 'tall':
parts.append(tube('TaperedTrunk', [(0, 0, 0), (.08, 0, 1), (-.1, .08, 2.2), (.03, 0, 3.8)], [.24, .18, .10, .025]))
for i, (z, radius) in enumerate([(1.45, .87), (2.15, .76), (2.85, .58), (3.45, .36)]):
parts.append(organic('SweptCrownTier', (0, 0, z), (radius, radius * .76, .84), ['PineMint', 'LeafLight', 'LeafShade'], i))
for i in range(12):
a = i * 2.4
z = 1.5 + (i % 4) * .46
parts.append(leaf('CrownTip', (.36 * math.cos(a), .3 * math.sin(a), z), (math.cos(a), math.sin(a), .4), .57, .12, 'LeafLight'))
else:
parts.append(tube('BentTrunk', [(0, 0, 0), (-.13, .03, .6), (-.03, 0, 1.25), (.22, .06, 2.15), (.35, .12, 2.7)], [.35, .27, .21, .13, .055]))
for j, (end, radius) in enumerate([((-1, .05, 2.45), .87), ((1.08, .1, 2.8), .92), ((.1, .70, 3.15), .93), ((-.45, -.52, 2.95), .80)]):
parts.append(tube('VisibleBranch', [(-.04, 0, 1.25), (end[0] * .5, end[1] * .5, 1.85), end], [.13, .09, .025]))
palette = ['LeafMint', 'LeafLight', 'LeafShade'] if kind == 'round' else ['LeafSage', 'LeafLight', 'LeafShade']
if j == 2:
palette[0] = 'LeafLight'
parts.append(organic('SculptedCanopy', end, (radius, radius * .82, radius * .77), palette, j + 4))
# Root flares and long bark ridges keep the trunk readable in a closeup.
for j in range(5):
a = math.tau * j / 5
parts.append(tube('RootFlare', [(.59 * math.cos(a), .5 * math.sin(a), .05), (.27 * math.cos(a), .25 * math.sin(a), .20), (0, 0, .55)], [.035, .10, .09]))
for j in range(3):
a = j * 2.1
parts.append(tube('BarkRibbon', [(.29 * math.cos(a), .29 * math.sin(a), .2), (-.12 + .22 * math.cos(a), .23 * math.sin(a), .7), (.18 * math.cos(a), .18 * math.sin(a), 1.3)], [.012, .018, .012], 'BarkLight', 6))
for j in range(16):
a = j * 2.4
start = (1.06 * math.cos(a), .83 * math.sin(a), 2.7 + .25 * math.sin(j))
parts.append(leaf('CanopyLeafAccent', start, (.35 * math.cos(a), .35 * math.sin(a), .35), .40, .13, 'LeafLight'))
return finish(name, parts, 'near tree with visible branched trunk and separate foliage', 'mesh on visible near tree only; no enclosing canopy box')
def flower_cluster(name, variety, count, seed):
rng = random.Random(seed)
parts = []
for n in range(count):
x, y = rng.uniform(-.58, .58), rng.uniform(-.4, .4)
h = rng.uniform(.54, .92) if variety != 'spike' else rng.uniform(.85, 1.22)
tip = Vector((x + .09, y, h))
parts.append(tube('CurvedStem', [(x, y, .025), (x - .035, y, h * .4), tuple(tip)], [.023, .019, .012], 'Stem', 7))
for j in range(2):
a = n * 1.7 + j * math.pi
parts.append(leaf('StemLeaf', (x, y, h * (.28 + j * .19)), (math.cos(a), math.sin(a), .25), .32, .085, 'LeafSage'))
if variety == 'spike':
for row in range(5):
for j in range(4):
a = math.tau * j / 4 + row * .6
base = tip + Vector((0, 0, -.37 + row * .085))
parts.append(leaf('LupineFloret', tuple(base), (math.cos(a), math.sin(a), .65), .16 - row * .016, .063, 'PetalLavender', .05))
else:
petal_mat = 'PetalCream' if variety == 'daisy' else 'PetalPink'
petals = 8 if variety == 'daisy' else 6
for j in range(petals):
a = math.tau * j / petals + n * .3
parts.append(leaf('SpoonPetal', tuple(tip), (math.cos(a), math.sin(a), .10), .25, .10, petal_mat, .09))
parts.append(organic('RecessedFlowerCenter', tuple(tip + Vector((0, 0, .025))), (.079, .079, .036), ['PollenHoney'], n, segments=12, rings=6))
return finish(name, parts, 'flower colony with petals, stems, leaves; no faces', 'none: small decorative vegetation')
def build_assets():
palette = {
'GroundMint': (.53, .74, .50), 'GroundSage': (.59, .77, .49), 'SoilWarm': (.52, .42, .29),
'LeafMint': (.45, .67, .44), 'LeafSage': (.53, .69, .41), 'LeafLight': (.62, .77, .46),
'LeafShade': (.31, .56, .45), 'PineMint': (.38, .66, .55), 'BarkHoney': (.52, .36, .21),
'BarkLight': (.68, .49, .30), 'Stem': (.32, .52, .27), 'GrassMint': (.49, .73, .46),
'GrassGold': (.69, .80, .46), 'PetalCream': (.98, .88, .59), 'PetalPink': (.90, .43, .56),
'PetalLavender': (.54, .42, .77), 'PollenHoney': (.94, .58, .13),
'StoneCream': (.59, .64, .57), 'StoneLight': (.75, .76, .65), 'StoneShade': (.42, .53, .49),
'StoneBlue': (.58, .70, .71), 'PathSand': (.76, .77, .57), 'RidgeTeal': (.54, .73, .72),
'RidgeLight': (.61, .78, .74), 'RidgeFar': (.66, .82, .83),
}
for name, color in palette.items():
material(name, color, .78 if name.startswith('Stone') else .67)
finish('MeadowClearing', [terrain('OpenPlayGround', 6.2, 5.0, .15)], 'flat playable clearing; arrange outside existing sphere positions', 'future gameplay floor only; top .14m authored, placement must align existing floor')
finish('GentleHill', [terrain('SoftSlopingLand', 5.5, 4.3, 1.55, True)], 'surrounding rolling terrain', 'none: peripheral scenery')
ridge = terrain('DistantRollingRidge', 12, 6, 4.0, True)
ridge.data.materials[0] = MATERIALS['RidgeTeal']
ridge.data.materials[1] = MATERIALS['RidgeLight']
finish('DistantRidge', [ridge], 'far terrain; atmospheric teal', 'none: distant decoration')
far = terrain('FarHorizon', 15, 7, 5.0, True)
far.data.materials[0] = MATERIALS['RidgeFar']
far.data.materials[1] = MATERIALS['RidgeFar']
finish('FarHorizon', [far], 'distant skyline terrain', 'none: distant decoration')
tree('RoundCanopyTree', 'round')
tree('SageCanopyTree', 'sage')
tree('TallWindTree', 'tall')
for name, color, seed in [('SageShrub', 'LeafSage', 8), ('MintShrub', 'LeafMint', 16)]:
parts = []
for n in range(4):
a = n * 2.4
end = (.35 * math.cos(a), .3 * math.sin(a), .52 + (n % 2) * .18)
parts.append(tube('ShrubWoodyBranch', [(0, 0, 0), (end[0] * .5, end[1] * .5, .35), end], [.085, .045, .015], 'BarkHoney', 7))
parts.append(organic('ShrubLeafMass', end, (.46, .36, .39), [color, 'LeafLight', 'LeafShade'], seed + n, segments=18, rings=10))
for n in range(12):
a = n * 2.4
parts.append(leaf('ReadableShrubLeaf', (.40 * math.cos(a), .35 * math.sin(a), .58), (math.cos(a), math.sin(a), .3), .27, .10, 'LeafLight'))
finish(name, parts, 'branched near shrub', 'none: small decorative vegetation')
fern_parts = []
for n in range(7):
a = n * math.tau / 7
direction = Vector((math.cos(a), math.sin(a), .55))
for j in range(5):
p = direction * (.10 + j * .12)
for side in (-1, 1):
sideways = Vector((-math.sin(a) * side, math.cos(a) * side, .15))
fern_parts.append(leaf('FernLeaflet', tuple(p), tuple(direction * .3 + sideways), .20 - .019 * j, .060, 'LeafSage'))
fern_parts.append(tube('FernSpine', [(0, 0, .02), tuple(direction * .30), tuple(direction * .70)], [.016, .011, .005], 'Stem', 6))
finish('FernCluster', fern_parts, 'individual curved fern fronds and leaflets', 'none: small decorative vegetation')
for name, seed in [('GrassTuftMint', 1), ('GrassTuftGold', 2)]:
rng = random.Random(seed)
parts = []
for n in range(19):
a = rng.random() * math.tau
length = rng.uniform(.21, .48)
parts.append(leaf('CurvedGrassBlade', (rng.uniform(-.28, .28), rng.uniform(-.22, .22), .025),
(.55 * math.cos(a), .55 * math.sin(a), 1), length, rng.uniform(.02, .043),
'GrassMint' if n % 3 else 'GrassGold', .04))
finish(name, parts, 'small grass colony with tapered curved blades', 'none: small decorative vegetation')
flower_cluster('DaisyColony', 'daisy', 5, 11)
flower_cluster('PinkCosmosColony', 'cosmos', 5, 21)
flower_cluster('LavenderLupineColony', 'spike', 4, 31)
for name, size, seed, blue in [('RoundLimestone', (1.12, .78, .73), 3, False), ('SlateBoulder', (1.0, .72, 1.15), 7, True),
('LowLimestone', (.72, .52, .32), 9, False)]:
obj = organic('CarvedStone', (0, 0, size[2]), size, ['StoneBlue' if blue else 'StoneCream', 'StoneLight', 'StoneShade'], seed, rock=True, segments=16, rings=10)
bevel = obj.modifiers.new('SoftStoneEdges', 'BEVEL')
bevel.width = .035
bevel.segments = 2
bevel.angle_limit = .35
bpy.context.view_layer.objects.active = obj
bpy.ops.object.modifier_apply(modifier=bevel.name)
finish(name, [obj], 'soft rock silhouette with readable broad planes', 'mesh on visible near rock only')
pebbles = []
for n, (x, y, s) in enumerate([(-.36, -.10, .22), (.20, .14, .29), (.46, -.14, .15), (-.06, -.25, .12)]):
pebbles.append(organic('RoundedPebble', (x, y, s * .43), (s, s * .75, s * .43), ['StoneCream', 'StoneLight'], n, rock=True, segments=12, rings=6))
finish('PebbleScatter', pebbles, 'small pebble scatter', 'none: small distant or ground decoration')
# One continuous tunnel surface, with an open center and non-box collider contract.
points, radii = [], []
for i in range(33):
a = math.pi - math.pi * i / 32
points.append((1.18 * math.cos(a), 0, .04 + 1.72 * math.sin(a)))
radii.append(.30 + .035 * math.sin(i * .8))
arch = tube('OpenStoneTunnel', points, radii, 'StoneCream', 12)
arch.data.materials.append(MATERIALS['StoneLight'])
arch.data.materials.append(MATERIALS['StoneShade'])
for face in arch.data.polygons:
face.material_index = 0
finish('OpenStoneArch', [arch], 'natural stone discovery arch with a physically open hole', 'nonconvex visible mesh collider; NEVER enclosing BoxCollider; do not ignore front raycast hits')
parts = []
for n in range(3):
slab = terrain('StoneTerrace', .76, .57, .15)
for v in slab.data.vertices:
v.co.x += (n - 1) * .76
v.co.z = v.co.z * (1.5 + n * 1.6)
for j in range(3):
slab.data.materials[j] = MATERIALS[['StoneCream', 'StoneLight', 'StoneShade'][j]]
parts.append(slab)
finish('MossStoneSteps', parts, 'three rounded stone terraces retaining stage discovery height differences', 'nonconvex mesh matching terraces; integration must preserve existing sphere positions')
def scene_setup(name):
scene = bpy.data.scenes.new(name)
scene.unit_settings.system = 'METRIC'
scene.unit_settings.scale_length = 1
scene.render.engine = 'CYCLES'
scene.cycles.samples = 32
scene.cycles.use_denoising = True
preferences = bpy.context.preferences.addons['cycles'].preferences
preferences.compute_device_type = 'OPTIX'
preferences.get_devices()
for device in preferences.devices:
device.use = device.type == 'OPTIX'
assert any(d.use and d.type == 'OPTIX' for d in preferences.devices), 'Expected coordinated RTX OPTIX render device'
scene.cycles.device = 'GPU'
scene.render.resolution_x = 1600
scene.render.resolution_y = 900
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = 'PNG'
scene.view_settings.view_transform = 'AgX'
scene.view_settings.look = 'AgX - Medium High Contrast'
world = bpy.data.worlds.new(name + '_CyanDay')
world.use_nodes = True
world.node_tree.nodes['Background'].inputs['Color'].default_value = (.40, .76, .85, 1)
world.node_tree.nodes['Background'].inputs['Strength'].default_value = .45
# Lighting stays physical; the visible cyan sky keeps its bright art direction.
nodes, links = world.node_tree.nodes, world.node_tree.links
sky = nodes.new('ShaderNodeBackground')
sky.name = 'VisibleCyanSky'
sky.inputs['Color'].default_value = (.12, .67, .85, 1)
sky.inputs['Strength'].default_value = 1
ray = nodes.new('ShaderNodeLightPath')
mix = nodes.new('ShaderNodeMixShader')
links.new(ray.outputs['Is Camera Ray'], mix.inputs[0])
links.new(nodes['Background'].outputs[0], mix.inputs[1])
links.new(sky.outputs[0], mix.inputs[2])
links.new(mix.outputs[0], nodes['World Output'].inputs['Surface'])
scene.world = world
for label, light_type, location, energy, size, color in [
('WarmSoftSun', 'AREA', (-10, -8, 18), 3800, 10, (1.0, .89, .72)),
('SkyFill', 'AREA', (8, -1, 12), 2200, 14, (.76, .91, 1.0)),
]:
data = bpy.data.lights.new(label, light_type)
data.energy, data.shape, data.size, data.color = energy, 'DISK', size, color
obj = bpy.data.objects.new(label, data)
scene.collection.objects.link(obj)
obj.location = location
obj.rotation_euler = (Vector((0, 3, 0)) - obj.location).to_track_quat('-Z', 'Y').to_euler()
sun = bpy.data.lights.new('Daylight', 'SUN')
sun.energy, sun.angle, sun.color = 2.0, .18, (1.0, .92, .79)
obj = bpy.data.objects.new('Daylight', sun)
scene.collection.objects.link(obj)
obj.rotation_euler = (.42, -.55, -.45)
return scene
def camera(scene, position, target, lens=44, ortho=None):
data = bpy.data.cameras.new(scene.name + '_ReviewCamera')
obj = bpy.data.objects.new(data.name, data)
scene.collection.objects.link(obj)
obj.location = position
obj.rotation_euler = (Vector(target) - obj.location).to_track_quat('-Z', 'Y').to_euler()
data.lens = lens
data.clip_end = 250
if ortho:
data.type, data.ortho_scale = 'ORTHO', ortho
scene.camera = obj
scene['cameraPurpose'] = 'slightly elevated three-quarter puzzle-compatible review; does not change game camera'
def instance(scene, name, loc, scale=1, rotation=0):
obj = ASSETS[name].copy()
obj.data = ASSETS[name].data
obj.name = name + '_SetDress'
scene.collection.objects.link(obj)
obj.location = loc
obj.scale = (scale,) * 3 if isinstance(scale, (float, int)) else scale
obj.rotation_euler.z = rotation
return obj
def landscape(name, theme):
scene = scene_setup(name)
scene['themeRuntimeIdentifier'] = ['Garden', 'Cloud', 'Workshop'][theme]
scene['stageRange'] = ['01-10', '11-20', '21-30'][theme]
scene['layoutStatus'] = 'concept preview only; original sphere positions and discoveries must be preserved during later integration'
# Continuous terrain continues behind the play area and to the horizon.
instance(scene, 'MeadowClearing', (0, 8, -.14), (9, 8, 1))
for i in range(8):
a = math.pi * .04 + math.pi * .92 * i / 7
instance(scene, 'DistantRidge', (math.cos(a) * 33, 25 + math.sin(a) * 18, -.2), 1.1 + .15 * math.sin(i), i * .7)
for i in range(6):
instance(scene, 'FarHorizon', (-44 + i * 17, 57 + (i % 2) * 4, -1.5), 1.3, i * .4)
for x, y, s in [(-10, 9, 1.2), (10, 13, 1.3), (-14, 19, 1.6), (18, 20, 1.5), (0, 22, 1.8)]:
instance(scene, 'GentleHill', (x, y, -.05), s)
if theme == 1:
for x, y in [(-8, 4), (9, 8), (-4, 13)]:
instance(scene, 'GentleHill', (x, y, -.03), .9)
positions = [(-7.6, 1, 1.3), (7.6, 5, 1.25), (-10, 13, 1.5), (11, 16, 1.55), (-4, 19, 1.4), (5, 24, 1.4), (-16, 21, 1.2), (20, 25, 1.8)]
for i, (x, y, s) in enumerate(positions):
instance(scene, ['RoundCanopyTree', 'SageCanopyTree', 'TallWindTree'][(i + theme) % 3], (x, y, .03), s, i * .9)
for i in range(24):
a = i * math.tau / 24
x, y = 8.4 * math.cos(a), 4 + 8 * math.sin(a)
if -4 < y < 2 and abs(x) < 6:
continue
instance(scene, 'MintShrub' if i % 2 else 'SageShrub', (x, y, .02), .7 + (i % 3) * .13, a)
# Irregular colonies, no luminous spherical decorations or face plants.
rng = random.Random(120 + theme)
for i in range(145 if theme == 1 else 100):
x, y = rng.uniform(-15, 15), rng.uniform(-9, 24)
if (x / 6.1) ** 2 + (y / 4.9) ** 2 < 1.1:
continue
obj_name = ['GrassTuftMint', 'GrassTuftGold', 'FernCluster'][i % 3]
instance(scene, obj_name, (x, y, .015), rng.uniform(.7, 1.25), rng.random() * math.tau)
if i % (2 if theme == 1 else 6) == 0:
flower_name = ['DaisyColony', 'PinkCosmosColony', 'LavenderLupineColony'][i % 3]
instance(scene, flower_name, (x + .45, y + .2, .02), rng.uniform(.7, 1.2), rng.random() * math.tau)
for i in range(14 if theme == 2 else 6):
a = i * 2.4
r = 7 + (i % 3) * 2.2
x, y = r * math.cos(a), 4 + r * math.sin(a)
instance(scene, ['RoundLimestone', 'SlateBoulder', 'LowLimestone'][i % 3], (x, y, .02), 1.0 + (.6 if theme == 2 else .1) * (i % 2), a)
instance(scene, 'PebbleScatter', (x + .65, y - .75, .02), .9, -a)
instance(scene, 'OpenStoneArch', (-5.5, 5.8, .03), 1.15, -.22)
instance(scene, 'MossStoneSteps', (5.0, 3.6, .03), 1.25, .36)
scene.view_layers[0].update()
ground_names = {'MeadowClearing', 'GentleHill', 'DistantRidge', 'FarHorizon'}
grounds = [o for o in scene.objects if o.get('role') and any(o.name.startswith(n + '_SetDress') for n in ground_names)]
terrain_bvhs = {g: BVHTree.FromPolygons([v.co for v in g.data.vertices], [list(p.vertices) for p in g.data.polygons]) for g in grounds}
for obj in list(scene.objects):
if obj.type != 'MESH' or obj in grounds:
continue
x, y = obj.location.x, obj.location.y
high = Vector((x, y, 40))
ground_z = 0
for ground in grounds:
inverse = ground.matrix_world.inverted()
point, _, _, _ = terrain_bvhs[ground].ray_cast(inverse @ high, (inverse.to_3x3() @ Vector((0, 0, -1))).normalized())
if point is not None:
ground_z = max(ground_z, (ground.matrix_world @ point).z)
obj.location.z = ground_z + .008
camera(scene, (17, -25, 16), (0, 7, 1.8), lens=34)
return scene
def catalog_scene(name, assets):
scene = scene_setup(name)
for i, asset_name in enumerate(assets):
obj = ASSETS[asset_name]
width = max(obj.dimensions.x, obj.dimensions.y)
scale = min(2.6, 2.8 / max(width, .1))
x, y = (i % 3 - 1) * 4.8, (i // 3) * 5.2
instance(scene, asset_name, (x, y, 0), scale)
data = bpy.data.curves.new(asset_name + '_Label', 'FONT')
data.body, data.size, data.align_x = asset_name, .23, 'CENTER'
data.extrude = .002
data.materials.append(MATERIALS['BarkHoney'])
label = bpy.data.objects.new(data.name, data)
scene.collection.objects.link(label)
label.location = (x, y - 1.8, .03)
instance(scene, 'MeadowClearing', (0, 4, -.14), (4, 4, 1))
camera(scene, (12, -20, 19), (0, 2.5, .85), ortho=18)
return scene
def closeup_scene(name, asset_names, positions, target, ortho):
scene = scene_setup(name)
for asset_name, position in zip(asset_names, positions):
instance(scene, asset_name, position)
instance(scene, 'MeadowClearing', (0, 0, -.14), 3)
camera(scene, (7, -12, 8), target, ortho=ortho)
return scene
def validate_source():
result = []
candidates = [o for o in bpy.data.objects if o.get('candidateStatus') and not o.name.endswith('_SetDress') and o.name in ASSETS]
assert len(candidates) == 21, f'Expected 21 authored candidate assets, found {len(candidates)}'
for obj in candidates:
assert obj.type == 'MESH' and obj.data.polygons, obj.name
assert obj.location.length < 1e-6, f'Nonzero native pivot: {obj.name}'
assert all(abs(s - 1) < 1e-6 for s in obj.scale), obj.name
assert abs(min(v.co.z for v in obj.data.vertices)) < .0001, f'Not floor pivot: {obj.name}'
assert all(math.isfinite(c) for v in obj.data.vertices for c in v.co), obj.name
assert obj.data.uv_layers.active and len(obj.data.uv_layers.active.data) == len(obj.data.loops), obj.name
assert all(math.isfinite(c) for loop in obj.data.uv_layers.active.data for c in loop.uv), obj.name
assert all(poly.area > 1e-10 for poly in obj.data.polygons), f'Degenerate face: {obj.name}'
if obj.name in {'MeadowClearing', 'GentleHill', 'DistantRidge', 'FarHorizon'}:
top = [p for p in obj.data.polygons if p.index < 1024]
assert all(p.normal.z > 0 for p in top), f'Inverted terrain top normals: {obj.name}'
assert obj.data.materials and all(m and m.use_nodes for m in obj.data.materials), obj.name
assert all(m.node_tree.nodes.get('Principled BSDF') for m in obj.data.materials), obj.name
triangles = sum(len(p.vertices) - 2 for p in obj.data.polygons)
assert triangles <= 16000, f'Excessive mobile candidate mesh: {obj.name} ({triangles})'
result.append(dict(name=obj.name, vertices=len(obj.data.vertices), triangles=triangles,
materials=[m.name for m in obj.data.materials], colliderGuidance=obj['colliderGuidance'],
boundsMin=[min(v.co[i] for v in obj.data.vertices) for i in range(3)],
boundsMax=[max(v.co[i] for v in obj.data.vertices) for i in range(3)]))
arch = bpy.data.objects['OpenStoneArch']
bvh = BVHTree.FromPolygons([v.co for v in arch.data.vertices], [list(p.vertices) for p in arch.data.polygons])
assert bvh.ray_cast(Vector((0, -2, .8)), Vector((0, 1, 0)), 4)[0] is None, 'Arch center hole blocked'
assert bvh.ray_cast(Vector((1.18, -2, .3)), Vector((0, 1, 0)), 4)[0] is not None, 'Arch side must occlude'
assert all(name in bpy.data.scenes for name in ['Preview_ClearMeadow', 'Preview_FlowerHills', 'Preview_StoneHills']), 'Missing assembled landscape previews'
return result
def verify_fbx_roundtrip(names):
"""Read the actual exported bytes; source mesh checks alone cannot validate FBX."""
scene = bpy.data.scenes.new('TemporaryFBXVerification')
bpy.context.window.scene = scene
results = []
for name in names:
previous = set(bpy.data.objects)
bpy.ops.import_scene.fbx(filepath=str(EXPORT / (name + '.fbx')))
imported = set(bpy.data.objects) - previous
imported_meshes = [o for o in imported if o.type == 'MESH']
assert len(imported_meshes) == 1, f'FBX must contain one mesh: {name}'
obj = imported_meshes[0]
native = ASSETS[name]
points = [obj.matrix_world @ v.co for v in obj.data.vertices]
for axis in range(3):
expected_min = min(v.co[axis] for v in native.data.vertices)
expected_max = max(v.co[axis] for v in native.data.vertices)
assert abs(min(p[axis] for p in points) - expected_min) < .005, f'FBX axis/pivot min mismatch: {name}'
assert abs(max(p[axis] for p in points) - expected_max) < .005, f'FBX axis/scale max mismatch: {name}'
assert obj.data.uv_layers.active, f'FBX UV lost: {name}'
assert len(obj.data.materials) == len(native.data.materials), f'FBX material slots lost: {name}'
results.append(dict(asset=name, importedMeshCount=1, worldBoundsMatchNative=True,
uvPresent=True, materialSlots=len(obj.data.materials)))
for item in imported:
bpy.data.objects.remove(item, do_unlink=True)
bpy.data.scenes.remove(scene)
bpy.context.window.scene = bpy.data.scenes['Preview_ClearMeadow']
return results
def file_record(path):
return dict(path=path.relative_to(ROOT).as_posix(), bytes=path.stat().st_size,
sha256=hashlib.sha256(path.read_bytes()).hexdigest())
def organize_collections(library, scenes):
terrain_names = {'MeadowClearing', 'GentleHill', 'DistantRidge', 'FarHorizon'}
tree_names = {'RoundCanopyTree', 'SageCanopyTree', 'TallWindTree'}
stone_names = {'RoundLimestone', 'SlateBoulder', 'LowLimestone', 'PebbleScatter', 'OpenStoneArch', 'MossStoneSteps'}
for scene in [library] + scenes:
groups = {}
for obj in list(scene.objects):
name = obj.name.split('_SetDress')[0]
group = ('01_Terrain' if name in terrain_names else '02_Trees' if name in tree_names else
'04_StoneDiscovery' if name in stone_names else '03_SmallVegetation' if obj.type == 'MESH' else '05_LightingCameraLabels')
if group not in groups:
collection = bpy.data.collections.new(scene.name + '_' + group)
scene.collection.children.link(collection)
groups[group] = collection
groups[group].objects.link(obj)
for collection in list(obj.users_collection):
if collection != groups[group]:
collection.objects.unlink(obj)
def verify_existing(review):
blend = SOURCE / 'MeadowCandidates.blend'
assert blend.is_file(), f'Missing native source: {blend}'
manifest_path = SOURCE / 'provenance.json'
assert manifest_path.is_file(), f'Missing provenance: {manifest_path}'
manifest = json.loads(manifest_path.read_text(encoding='utf-8'))
for record in manifest['files']:
path = ROOT / record['path']
assert path.is_file(), f'Missing resource: {path}'
assert path.stat().st_size == record['bytes'], f'Byte mismatch: {path}'
assert hashlib.sha256(path.read_bytes()).hexdigest() == record['sha256'], f'Hash mismatch: {path}'
bpy.ops.wm.open_mainfile(filepath=str(blend))
for obj in bpy.data.objects:
if obj.name in manifest['assetNames']:
ASSETS[obj.name] = obj
meshes = validate_source()
for name in manifest['assetNames']:
path = EXPORT / (name + '.fbx')
assert path.is_file() and path.stat().st_size > 8000, f'Missing/empty export: {path}'
for name in manifest['reviewRenders']:
path = review / name
assert path.is_file() and path.stat().st_size > 10000, f'Missing review render: {path}'
roundtrip = verify_fbx_roundtrip(manifest['assetNames'])
report = dict(status='PASS', sourceReopened=True, meshContracts=meshes, fbxRoundtrip=roundtrip,
resourcesAndHashesChecked=len(manifest['files']),
archHoleRayPass=True, archSideRayOccludes=True,
unityImportAndGameplay='NOT_RUN; user candidate review required')
(review / 'source-verification.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
print('MEADOW_VERIFY_PASS ' + json.dumps(dict(assets=len(meshes), sourceReopened=True, resources=len(manifest['files']))))
def main():
argv = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []
parser = argparse.ArgumentParser()
parser.add_argument('--review-dir', type=Path, default=DEFAULT_REVIEW)
parser.add_argument('--verify-only', action='store_true')
args = parser.parse_args(argv)
review = args.review_dir.resolve()
assert not any(p.resolve() == review or p.resolve() in review.parents for p in [SOURCE, EXPORT]), 'Review renders must not overwrite authored source or FBX export folders'
review.mkdir(parents=True, exist_ok=True)
if args.verify_only:
verify_existing(review)
return
SOURCE.mkdir(parents=True, exist_ok=True)
EXPORT.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
library = bpy.context.scene
library.name = 'AssetLibrary_21NativeCandidates'
library.unit_settings.system = 'METRIC'
library.unit_settings.scale_length = 1
collection = bpy.data.collections.new('Meadow_NativeAssetLibrary')
library.collection.children.link(collection)
bpy.context.view_layer.active_layer_collection = bpy.context.view_layer.layer_collection.children[collection.name]
build_assets()
previews = [landscape('Preview_ClearMeadow', 0), landscape('Preview_FlowerHills', 1), landscape('Preview_StoneHills', 2)]
catalogs = [
catalog_scene('Catalog_TerrainDiscovery', ['MeadowClearing', 'GentleHill', 'DistantRidge', 'FarHorizon', 'OpenStoneArch', 'MossStoneSteps']),
catalog_scene('Catalog_TreesShrubs', ['RoundCanopyTree', 'SageCanopyTree', 'TallWindTree', 'MintShrub', 'SageShrub', 'FernCluster']),
catalog_scene('Catalog_FlowersGrass', ['DaisyColony', 'PinkCosmosColony', 'LavenderLupineColony', 'GrassTuftMint', 'GrassTuftGold']),
catalog_scene('Catalog_RockPlanes', ['RoundLimestone', 'SlateBoulder', 'LowLimestone', 'PebbleScatter']),
closeup_scene('Closeup_BranchesAndLeaves', ['RoundCanopyTree', 'FernCluster', 'SageShrub'], [(0, 0, 0), (-1.4, -.9, 0), (1.6, .6, 0)], (0, 0, 1.95), 9.6),
closeup_scene('Closeup_PetalsStemsLeaves', ['DaisyColony', 'PinkCosmosColony', 'LavenderLupineColony'], [(-1, 0, 0), (.5, .15, 0), (1.6, .5, 0)], (.2, 0, .6), 4.4),
closeup_scene('Closeup_OpenArchAndTerraces', ['OpenStoneArch', 'MossStoneSteps', 'SlateBoulder'], [(-1.5, .4, 0), (1.5, 0, 0), (2.1, 1.5, 0)], (0, .4, .8), 8),
]
mesh_report = validate_source()
organize_collections(library, previews + catalogs)
bpy.context.window.scene = library
for name, obj in ASSETS.items():
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(EXPORT / (name + '.fbx')), **SETTINGS)
print('EXPORTED ' + name)
# Asset library objects stay at their native pivots. Separate linked preview scenes
# provide layout without sacrificing reusable asset transforms.
bpy.context.window.scene = previews[0]
bpy.context.preferences.filepaths.save_version = 0
blend = SOURCE / 'MeadowCandidates.blend'
bpy.ops.wm.save_as_mainfile(filepath=str(blend))
render_names, render_records = [], []
for scene in previews + catalogs:
filename = scene.name + '.png'
scene.render.filepath = str(review / filename)
bpy.ops.render.render(write_still=True, scene=scene.name)
assert (review / filename).is_file(), f'Render not saved: {filename}'
render_names.append(filename)
render_records.append(dict(file=filename, scene=scene.name, frame=scene.frame_current,
source=file_record(blend), camera=scene.camera.name,
cameraLocation=list(scene.camera.location), cameraEuler=list(scene.camera.rotation_euler),
cameraType=scene.camera.data.type, lens=scene.camera.data.lens,
orthoScale=scene.camera.data.ortho_scale,
resolution=[scene.render.resolution_x, scene.render.resolution_y],
engine=scene.render.engine, device=scene.cycles.device,
samples=scene.cycles.samples, denoise=scene.cycles.use_denoising,
bytes=(review / filename).stat().st_size,
sha256=hashlib.sha256((review / filename).read_bytes()).hexdigest()))
print('REVIEW_RENDER ' + filename)
files = [file_record(blend), file_record(Path(__file__))] + [file_record(EXPORT / (name + '.fbx')) for name in sorted(ASSETS)]
manifest = dict(schemaVersion=1, status='CANDIDATE_USER_REVIEW_REQUIRED_NOT_ADOPTED',
creator='MagnetismHeroes project-authored native Blender geometry via tools/models/build_meadow.py',
license='Self-created project artwork; no third-party mesh or texture assets; no copied reference characters, guns, UI, or composition',
reference='cartoon-landscape.png viewed only for cyan sky, rounded nature, pastel vegetation, warm soft daylight',
tool='Blender ' + bpy.app.version_string, toolBuildHash=bpy.app.build_hash.decode(),
source='SourceAssets/Models/Meadow/MeadowCandidates.blend',
coordinateConvention='meters Z-up native; FBX -Z forward/Y-up; applied unit scale; floor pivots; later Unity globalScale=1 bakeAxisConversion=true and preserve imported root rotation',
exportSettings={k: sorted(v) if isinstance(v, set) else v for k, v in SETTINGS.items()},
materialTransfer='native named material slots; constant Base Color and Roughness; UVMap; no Blender-only procedural surface or external texture dependency',
materials=[dict(name=m.name, authoredSRGB=list(m['authoredSRGB']), linearBaseColor=list(m.diffuse_color),
roughness=m.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value) for m in MATERIALS.values()],
assetNames=sorted(ASSETS), meshContracts=mesh_report,
previewScenes=[s.name for s in previews], reviewRenders=render_names,
reviewState=dict(blender='native source contracts and saved review renders; visual inspection recorded separately',
userApproval='PENDING', unityImport='NOT_RUN', gameplayDiscovery='NOT_RUN', mobilePerformance='NOT_RUN'), files=files)
(SOURCE / 'provenance.json').write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
(review / 'render-manifest.json').write_text(json.dumps(render_records, indent=2) + '\n', encoding='utf-8')
verify_existing(review)
if __name__ == '__main__':
main()