146 lines
4.9 KiB
Python
146 lines
4.9 KiB
Python
# blender_render_script.py
|
|
import bpy
|
|
import os
|
|
import math
|
|
from array import array
|
|
from mathutils import Vector
|
|
|
|
# NOTE: this script runs inside Blender's bundled Python, which usually has
|
|
# neither PIL nor numpy, so the 2x2 grid is assembled with Blender's own image
|
|
# API instead.
|
|
|
|
# These will be replaced by the main script before running
|
|
MODEL_PATH = r"{{MODEL_PATH}}"
|
|
OUTPUT_GRID_PATH = r"{{OUTPUT_GRID_PATH}}"
|
|
|
|
# ====================== BLENDER RENDER LOGIC ======================
|
|
# Start from a clean scene. open_mainfile("") raises, so just delete the
|
|
# default cube/camera/light instead.
|
|
bpy.ops.object.select_all(action='SELECT')
|
|
bpy.ops.object.delete()
|
|
|
|
# Import GLB (Blender's glTF importer handles KHR_draco_mesh_compression)
|
|
bpy.ops.import_scene.gltf(filepath=MODEL_PATH)
|
|
|
|
# Select meshes and compute bounds
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
for obj in bpy.data.objects:
|
|
if obj.type == 'MESH':
|
|
obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = obj
|
|
|
|
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
|
|
|
|
# Calculate scene bounds
|
|
min_pt = Vector((float('inf'),) * 3)
|
|
max_pt = Vector((-float('inf'),) * 3)
|
|
|
|
for obj in bpy.data.objects:
|
|
if obj.type == 'MESH':
|
|
for vert in obj.data.vertices:
|
|
world_coord = obj.matrix_world @ vert.co
|
|
min_pt = Vector((min(min_pt.x, world_coord.x), min(min_pt.y, world_coord.y), min(min_pt.z, world_coord.z)))
|
|
max_pt = Vector((max(max_pt.x, world_coord.x), max(max_pt.y, world_coord.y), max(max_pt.z, world_coord.z)))
|
|
|
|
center = (min_pt + max_pt) / 2
|
|
extents = max_pt - min_pt
|
|
max_dim = max(extents.x, extents.y, extents.z) or 1.0
|
|
|
|
print(f"Blender bounds → Center: {center} | Max Dim: {max_dim:.3f}")
|
|
|
|
# Scene settings
|
|
scene = bpy.context.scene
|
|
scene.render.resolution_x = 512
|
|
scene.render.resolution_y = 512
|
|
scene.render.image_settings.file_format = 'PNG'
|
|
# EEVEE was renamed BLENDER_EEVEE_NEXT in Blender 4.2+; pick whatever this build
|
|
# actually offers so the script works across versions.
|
|
engine_options = scene.render.bl_rna.properties['engine'].enum_items.keys()
|
|
if 'BLENDER_EEVEE_NEXT' in engine_options:
|
|
scene.render.engine = 'BLENDER_EEVEE_NEXT'
|
|
elif 'BLENDER_EEVEE' in engine_options:
|
|
scene.render.engine = 'BLENDER_EEVEE'
|
|
scene.eevee.taa_render_samples = 64
|
|
scene.render.film_transparent = True
|
|
|
|
# Add sunlight
|
|
light = bpy.data.lights.new(name="Sun", type='SUN')
|
|
light.energy = 5.0
|
|
light_node = bpy.data.objects.new("Sun", light)
|
|
bpy.context.collection.objects.link(light_node)
|
|
light_node.rotation_euler = (math.radians(45), 0, math.radians(30))
|
|
|
|
# Camera
|
|
cam_data = bpy.data.cameras.new(name='Camera')
|
|
cam = bpy.data.objects.new('Camera', cam_data)
|
|
bpy.context.collection.objects.link(cam)
|
|
scene.camera = cam
|
|
|
|
distance = max_dim * 2.5
|
|
|
|
views = [
|
|
("front", 8, 0),
|
|
("side", 5, 90),
|
|
("top", 75, 45),
|
|
("angle", -12, 35),
|
|
]
|
|
|
|
temp_dir = os.path.dirname(OUTPUT_GRID_PATH)
|
|
image_paths = []
|
|
|
|
for i, (name, pitch, yaw) in enumerate(views):
|
|
pitch_rad = math.radians(pitch)
|
|
yaw_rad = math.radians(yaw)
|
|
|
|
dx = distance * math.cos(pitch_rad) * math.sin(yaw_rad)
|
|
dy = -distance * math.cos(pitch_rad) * math.cos(yaw_rad)
|
|
dz = distance * math.sin(pitch_rad)
|
|
|
|
cam.location = center + Vector((dx, dy, dz))
|
|
direction = center - cam.location
|
|
cam.rotation_euler = direction.to_track_quat('-Z', 'Y').to_euler()
|
|
|
|
output = os.path.join(temp_dir, f"view_{i}.png")
|
|
scene.render.filepath = output
|
|
bpy.ops.render.render(write_still=True)
|
|
image_paths.append(output)
|
|
print(f"Rendered view: {name}")
|
|
|
|
# Create 2x2 grid using only Blender's image API (no PIL/numpy required).
|
|
GRID = 1024
|
|
bg = (30 / 255.0, 30 / 255.0, 35 / 255.0, 1.0)
|
|
dest = array('f', list(bg) * (GRID * GRID))
|
|
|
|
# (screen-space top-left x, top-left y) for each tile; Blender images are stored
|
|
# bottom-up, so convert the y origin accordingly.
|
|
placements = [(0, 0), (512, 0), (0, 512), (512, 512)]
|
|
|
|
for path, (px, py) in zip(image_paths, placements):
|
|
img = bpy.data.images.load(path)
|
|
sw, sh = img.size
|
|
src = array('f', [0.0] * (sw * sh * 4))
|
|
img.pixels.foreach_get(src)
|
|
|
|
base_row = GRID - py - sh # bottom-up row of the tile's bottom edge
|
|
for sr in range(sh):
|
|
grid_row = base_row + sr
|
|
if grid_row < 0 or grid_row >= GRID:
|
|
continue
|
|
d0 = (grid_row * GRID + px) * 4
|
|
s0 = sr * sw * 4
|
|
dest[d0:d0 + sw * 4] = src[s0:s0 + sw * 4]
|
|
bpy.data.images.remove(img)
|
|
|
|
grid_img = bpy.data.images.new("grid", width=GRID, height=GRID, alpha=True)
|
|
grid_img.pixels.foreach_set(dest)
|
|
grid_img.filepath_raw = OUTPUT_GRID_PATH
|
|
grid_img.file_format = 'PNG'
|
|
grid_img.save()
|
|
print(f"✅ Blender 4-view grid saved → {OUTPUT_GRID_PATH}")
|
|
|
|
# Cleanup temp view files
|
|
for p in image_paths:
|
|
try:
|
|
os.remove(p)
|
|
except:
|
|
pass |