593 lines
21 KiB
Python
593 lines
21 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Blender渲染服务
|
||
处理Blender渲染逻辑,适配Linux服务器环境
|
||
"""
|
||
|
||
import os
|
||
import subprocess
|
||
import asyncio
|
||
import tempfile
|
||
import shutil
|
||
import math
|
||
import time
|
||
from typing import Optional, Tuple
|
||
import logging
|
||
|
||
from models import RenderRequest
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class BlenderRenderService:
|
||
"""Blender渲染服务类"""
|
||
|
||
def __init__(self, blender_path: str = None):
|
||
"""
|
||
初始化Blender服务
|
||
|
||
Args:
|
||
blender_path: Blender可执行文件路径,如果为None则自动检测
|
||
"""
|
||
self.blender_path = blender_path or self._find_blender_path()
|
||
self.output_dir = "/data/Isometquick/"
|
||
self._ensure_output_dir()
|
||
|
||
def _find_blender_path(self) -> str:
|
||
"""自动查找Blender路径"""
|
||
# Linux常见路径
|
||
possible_paths = [
|
||
"/data/blender/blender-4.2.11-linux-x64/blender",
|
||
"blender" # 系统PATH中
|
||
]
|
||
|
||
for path in possible_paths:
|
||
if shutil.which(path):
|
||
logger.info(f"找到Blender: {path}")
|
||
return path
|
||
|
||
# 如果都找不到,使用默认值
|
||
logger.warning("未找到Blender,使用默认路径")
|
||
return "blender"
|
||
|
||
def _ensure_output_dir(self):
|
||
"""确保输出目录存在"""
|
||
try:
|
||
os.makedirs(self.output_dir, exist_ok=True)
|
||
logger.info(f"输出目录设置为: {self.output_dir}")
|
||
except PermissionError as e:
|
||
logger.error(f"无法创建输出目录 {self.output_dir}: 权限不足")
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"创建输出目录失败 {self.output_dir}: {e}")
|
||
raise
|
||
|
||
def check_blender_available(self) -> bool:
|
||
"""检查Blender是否可用"""
|
||
try:
|
||
result = subprocess.run(
|
||
[self.blender_path, "--version"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=10
|
||
)
|
||
return result.returncode == 0
|
||
except Exception as e:
|
||
logger.error(f"Blender检查失败: {e}")
|
||
return False
|
||
|
||
def _calculate_room_parameters(self, request: RenderRequest) -> Tuple[float, float, float]:
|
||
"""
|
||
计算房间参数:floor_scale_value, x_extrude_value, y_extrude_value
|
||
|
||
Returns:
|
||
(floor_scale_value, x_extrude_value, y_extrude_value)
|
||
"""
|
||
room_length = request.room.length # Y轴(长度)
|
||
room_width = request.room.width # X轴(宽度)
|
||
|
||
# 判断长宽是否相等
|
||
if room_length == room_width:
|
||
# 长宽相等,按原逻辑
|
||
floor_scale_value = room_width # 使用宽度作为基准
|
||
x_extrude_value = 0.0
|
||
y_extrude_value = 0.0
|
||
else:
|
||
# 长宽不等,需要调整
|
||
if room_length > room_width:
|
||
# 长比宽大(Y轴比X轴大)
|
||
floor_scale_value = room_width # 使用较小的值(宽度)
|
||
x_extrude_value = 0.0
|
||
y_extrude_value = room_length - room_width # Y轴延伸
|
||
else:
|
||
# 宽比长大(X轴比Y轴大)
|
||
floor_scale_value = room_length # 使用较小的值(长度)
|
||
x_extrude_value = room_width - room_length # X轴延伸
|
||
y_extrude_value = 0.0
|
||
|
||
logger.info(
|
||
f"房间参数计算: floor_scale={floor_scale_value}, x_extrude={x_extrude_value}, y_extrude={y_extrude_value}")
|
||
return floor_scale_value, x_extrude_value, y_extrude_value
|
||
|
||
def _calculate_camera_position(self, request: RenderRequest) -> tuple:
|
||
"""
|
||
根据房间尺寸和视图类型计算摄像机位置
|
||
|
||
Returns:
|
||
(x, y, z) 摄像机位置坐标
|
||
"""
|
||
room_length = request.room.length # Y轴(长度)
|
||
room_width = request.room.width # X轴(宽度)
|
||
camera_height = request.camera.height # Z轴
|
||
|
||
# 判断长宽是否相等
|
||
if room_length == room_width:
|
||
# 长宽相等,使用原来的逻辑
|
||
if request.camera.view_type == 1:
|
||
# 正视图: x=0, y=-(房间的长度/2-1), z=摄像机垂直高度
|
||
x = 0
|
||
y = -(room_length / 2 - 1)
|
||
z = camera_height
|
||
else:
|
||
# 侧视图: x=(房间的宽度/2-1), y=-(房间的长度/2-1), z=摄像机垂直高度
|
||
x = room_width / 2 - 1
|
||
y = -(room_length / 2 - 1)
|
||
z = camera_height
|
||
else:
|
||
# 长宽不等,使用新的逻辑
|
||
if room_length > room_width:
|
||
# 长比宽大(Y轴比X轴大)
|
||
if request.camera.view_type == 1:
|
||
# 正视图: x=0, y=-(房间的长度-(房间的宽度/2)-1), z=摄像机垂直高度
|
||
x = 0
|
||
y = -(room_length - (room_width / 2) - 1)
|
||
z = camera_height
|
||
else:
|
||
# 侧视图: x=(房间的宽度/2-1), y=-(房间的长度-(房间的宽度/2)-1), z=摄像机垂直高度
|
||
x = room_width / 2 - 1
|
||
y = -(room_length - (room_width / 2) - 1)
|
||
z = camera_height
|
||
else:
|
||
# 宽比长大(X轴比Y轴大)
|
||
if request.camera.view_type == 1:
|
||
# 正视图: x=(房间的宽度-房间的长度)/2, y=-(房间的长度/2-1), z=摄像机垂直高度
|
||
x = (room_width - room_length) / 2
|
||
y = -(room_length / 2 - 1)
|
||
z = camera_height
|
||
else:
|
||
# 侧视图: x=(房间的宽度-(房间的长度/2)-1), y=-(房间的长度/2-1), z=摄像机垂直高度
|
||
x = room_width - (room_length / 2) - 1
|
||
y = -(room_length / 2 - 1)
|
||
z = camera_height
|
||
|
||
logger.info(f"相机位置计算: x={x}, y={y}, z={z}")
|
||
return (x, y, z)
|
||
|
||
def _calculate_camera_rotation(self, request: RenderRequest) -> tuple:
|
||
"""
|
||
根据视图类型计算摄像机旋转角度
|
||
|
||
Returns:
|
||
(rx, ry, rz) 摄像机旋转角度(弧度)
|
||
"""
|
||
if request.camera.view_type == 1:
|
||
# 正视图: 90, 0, 0
|
||
rx = math.radians(90)
|
||
ry = math.radians(0)
|
||
rz = math.radians(0)
|
||
else:
|
||
# 侧视图: 90, 0, 相机旋转角度
|
||
rx = math.radians(90)
|
||
ry = math.radians(0)
|
||
rz = math.radians(request.camera.rotation_angle)
|
||
|
||
return (rx, ry, rz)
|
||
|
||
def _generate_blender_script(self, task_id: str, request: RenderRequest) -> str:
|
||
"""生成Blender Python脚本"""
|
||
|
||
# 计算房间参数
|
||
floor_scale_value, x_extrude_value, y_extrude_value = self._calculate_room_parameters(
|
||
request)
|
||
|
||
# 计算摄像机位置和旋转
|
||
camera_pos = self._calculate_camera_position(request)
|
||
camera_rot = self._calculate_camera_rotation(request)
|
||
|
||
# 设置输出文件路径
|
||
output_file = os.path.join(self.output_dir, f"render_{task_id}.png")
|
||
|
||
# 渲染引擎映射
|
||
engine_map = {
|
||
"workbench": "BLENDER_WORKBENCH",
|
||
"eevee": "BLENDER_EEVEE_NEXT",
|
||
"cycles": "CYCLES"
|
||
}
|
||
render_engine = engine_map.get(
|
||
request.render.engine, "BLENDER_WORKBENCH")
|
||
|
||
# 计算灯光高度(房间高度减一米)
|
||
light_height = request.room.height - 1.0
|
||
|
||
script = f'''
|
||
import bpy
|
||
import bmesh
|
||
import os
|
||
import math
|
||
import time
|
||
|
||
def disable_problematic_addons():
|
||
"""禁用可能导致问题的插件"""
|
||
try:
|
||
problematic_addons = ['bl_tool']
|
||
for addon in problematic_addons:
|
||
if addon in bpy.context.preferences.addons:
|
||
bpy.ops.preferences.addon_disable(module=addon)
|
||
print(f"已禁用插件: {{addon}}")
|
||
except:
|
||
pass
|
||
|
||
def create_isometric_room():
|
||
"""创建等轴测房间"""
|
||
try:
|
||
disable_problematic_addons()
|
||
|
||
# 清除默认立方体
|
||
if "Cube" in bpy.data.objects:
|
||
bpy.data.objects.remove(bpy.data.objects["Cube"], do_unlink=True)
|
||
|
||
# 设置3D游标位置到原点
|
||
bpy.context.scene.cursor.location = (0.0, 0.0, 0.0)
|
||
|
||
# 检查Isometquick插件
|
||
if not hasattr(bpy.context.scene, 'iso_tool'):
|
||
print("错误: Isometquick插件未正确加载")
|
||
return False
|
||
|
||
# 获取Isometquick设置
|
||
iso_tool = bpy.context.scene.iso_tool
|
||
|
||
# 启用需要的组件
|
||
iso_tool.create_floor = True
|
||
iso_tool.create_left_wall = True
|
||
iso_tool.create_right_wall = True
|
||
iso_tool.create_left_light = True
|
||
iso_tool.create_right_light = True
|
||
iso_tool.create_hidden_ceiling = True
|
||
iso_tool.create_hidden_leftwall = False
|
||
iso_tool.create_hidden_rightwall = False
|
||
|
||
# 找到3D视图区域
|
||
view3d_area = None
|
||
for area in bpy.context.screen.areas:
|
||
if area.type == 'VIEW_3D':
|
||
view3d_area = area
|
||
break
|
||
|
||
if view3d_area is None:
|
||
print("错误: 找不到3D视图区域")
|
||
return False
|
||
|
||
# 临时切换到3D视图上下文
|
||
with bpy.context.temp_override(area=view3d_area):
|
||
# 使用计算出的房间参数创建房间
|
||
print(f"房间参数: floor_scale={floor_scale_value}, x_extrude={x_extrude_value}, y_extrude={y_extrude_value}")
|
||
result = bpy.ops.object.isometric_operator(
|
||
floor_scale_value={floor_scale_value},
|
||
wall_height_value={request.room.height},
|
||
equal_thickness_value=0.15,
|
||
wall_thickness_value=0.0,
|
||
floor_thickness_value=0.0,
|
||
x_extrude_value={x_extrude_value},
|
||
y_extrude_value={y_extrude_value}
|
||
)
|
||
|
||
if result == {{'FINISHED'}}:
|
||
print("等轴测房间创建完成!")
|
||
fix_visibility()
|
||
return True
|
||
else:
|
||
print("房间创建失败")
|
||
return False
|
||
|
||
except Exception as e:
|
||
print(f"创建房间时出现错误: {{str(e)}}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return False
|
||
|
||
def fix_visibility():
|
||
"""修复对象可见性设置"""
|
||
try:
|
||
# 设置光源不可见
|
||
light_objects = ["ISO Emission Left", "ISO Emission Right"]
|
||
for obj_name in light_objects:
|
||
if obj_name in bpy.data.objects:
|
||
obj = bpy.data.objects[obj_name]
|
||
if hasattr(obj, 'visibility_camera'):
|
||
obj.visibility_camera = False
|
||
elif hasattr(obj, 'cycles_visibility'):
|
||
obj.cycles_visibility.camera = False
|
||
print(f"已设置 {{obj_name}} 的可见性")
|
||
|
||
# 设置天花板可见
|
||
if "Hidden Ceiling" in bpy.data.objects:
|
||
ceiling_obj = bpy.data.objects["Hidden Ceiling"]
|
||
if hasattr(ceiling_obj, 'visibility_camera'):
|
||
ceiling_obj.visibility_camera = True
|
||
elif hasattr(ceiling_obj, 'cycles_visibility'):
|
||
ceiling_obj.cycles_visibility.camera = True
|
||
ceiling_obj.hide_viewport = False
|
||
ceiling_obj.hide_render = False
|
||
print("已设置 Hidden Ceiling 为可见")
|
||
|
||
except Exception as e:
|
||
print(f"设置可见性时出现错误: {{str(e)}}")
|
||
|
||
def setup_render_settings():
|
||
"""设置渲染参数"""
|
||
scene = bpy.context.scene
|
||
|
||
# 设置渲染引擎为EEVEE_NEXT
|
||
scene.render.engine = 'BLENDER_EEVEE_NEXT'
|
||
print(f"已设置渲染引擎为: BLENDER_EEVEE_NEXT")
|
||
|
||
# 设置EEVEE参数
|
||
try:
|
||
if hasattr(scene.eevee, 'taa_render_samples'):
|
||
scene.eevee.taa_render_samples = 64
|
||
print(f"已设置EEVEE渲染采样: {{scene.eevee.taa_render_samples}}")
|
||
elif hasattr(scene.eevee, 'taa_samples'):
|
||
scene.eevee.taa_samples = 64
|
||
print(f"已设置EEVEE采样: {{scene.eevee.taa_samples}}")
|
||
else:
|
||
print("警告: 无法找到EEVEE采样设置,使用默认值")
|
||
|
||
# 启用屏幕空间反射
|
||
if hasattr(scene.eevee, 'use_ssr'):
|
||
scene.eevee.use_ssr = True
|
||
scene.eevee.use_ssr_refraction = True
|
||
print("已启用屏幕空间反射")
|
||
|
||
# 启用环境光遮蔽
|
||
if hasattr(scene.eevee, 'use_gtao'):
|
||
scene.eevee.use_gtao = True
|
||
print("已启用环境光遮蔽")
|
||
|
||
# 启用体积雾
|
||
if hasattr(scene.eevee, 'use_volumetric_lights'):
|
||
scene.eevee.use_volumetric_lights = True
|
||
print("已启用体积光照")
|
||
|
||
except AttributeError as e:
|
||
print(f"EEVEE设置警告: {{e}}")
|
||
print("使用默认EEVEE渲染设置")
|
||
|
||
# 设置分辨率
|
||
scene.render.resolution_x = {request.render.resolution_x}
|
||
scene.render.resolution_y = {request.render.resolution_y}
|
||
scene.render.resolution_percentage = 100
|
||
|
||
# 设置输出格式
|
||
scene.render.image_settings.file_format = 'PNG'
|
||
scene.render.image_settings.color_mode = 'RGBA'
|
||
|
||
# 设置输出路径
|
||
scene.render.filepath = "{output_file}"
|
||
|
||
print(f"渲染设置完成,输出路径: {{scene.render.filepath}}")
|
||
print(f"分辨率: {{scene.render.resolution_x}}x{{scene.render.resolution_y}}")
|
||
return scene.render.filepath
|
||
|
||
def setup_camera_and_lighting():
|
||
"""设置摄像机和照明(180W点光源,高度为房间高度减一米)"""
|
||
# 计算灯光高度(房间高度减一米)
|
||
room_height = {request.room.height}
|
||
light_height = room_height - 1.0
|
||
|
||
# 设置摄像机
|
||
camera = None
|
||
if "Camera" in bpy.data.objects:
|
||
camera = bpy.data.objects["Camera"]
|
||
else:
|
||
bpy.ops.object.camera_add(location={camera_pos})
|
||
camera = bpy.context.active_object
|
||
camera.name = "API Camera"
|
||
|
||
# 设置摄像机位置和旋转
|
||
camera.location = {camera_pos}
|
||
camera.rotation_euler = {camera_rot}
|
||
|
||
# 设置为透视投影
|
||
camera.data.type = 'PERSP'
|
||
camera.data.lens = 35.0
|
||
camera.data.sensor_width = 35.0
|
||
camera.data.sensor_fit = 'AUTO'
|
||
|
||
# 设置为场景的活动摄像机
|
||
bpy.context.scene.camera = camera
|
||
print("摄像机设置完成")
|
||
|
||
# 隐藏ISO Emission灯光(如果存在)
|
||
light_objects = ["ISO Emission Left", "ISO Emission Right"]
|
||
for obj_name in light_objects:
|
||
if obj_name in bpy.data.objects:
|
||
light_obj = bpy.data.objects[obj_name]
|
||
# 隐藏发光对象
|
||
light_obj.hide_render = True
|
||
light_obj.hide_viewport = True
|
||
print(f"已隐藏 {{obj_name}}")
|
||
|
||
# 创建单个180W点光源
|
||
try:
|
||
# 计算点光源位置(房间中心上方)
|
||
room_length = {request.room.length} # Y轴
|
||
room_width = {request.room.width} # X轴
|
||
|
||
# 点光源位置:房间中心上方
|
||
light_x = 0 # X轴中心
|
||
light_y = 0 # Y轴中心
|
||
light_z = light_height
|
||
|
||
# 创建点光源
|
||
bpy.ops.object.light_add(type='POINT', location=(light_x, light_y, light_z))
|
||
point_light = bpy.context.active_object
|
||
point_light.name = "Main Point Light"
|
||
point_light.data.energy = 180 # 180W
|
||
|
||
# 设置软衰减
|
||
point_light.data.falloff_type = 'INVERSE_SQUARE' # 平方反比衰减(物理准确)
|
||
|
||
# 设置阴影
|
||
point_light.data.use_shadow = True # 启用阴影
|
||
point_light.data.shadow_soft_size = 0.5 # 0.5米半径,产生柔和阴影
|
||
|
||
# EEVEE渲染器的阴影设置
|
||
if hasattr(point_light.data, 'shadow_buffer_size'):
|
||
point_light.data.shadow_buffer_size = 2048 # 阴影贴图分辨率
|
||
|
||
# Cycles渲染器的阴影设置
|
||
if hasattr(point_light.data, 'cycles'):
|
||
point_light.data.cycles.cast_shadow = True # Cycles中启用阴影投射
|
||
point_light.data.cycles.use_multiple_importance_sampling = True # 启用重要性采样
|
||
|
||
# 设置衰减距离(可选)
|
||
# point_light.data.cutoff_distance = 15.0 # 15米后完全衰减
|
||
# point_light.data.use_custom_distance = True
|
||
|
||
print(f"已创建180W点光源(软衰减+阴影)")
|
||
print(f"点光源位置: x={{light_x}}, y={{light_y}}, z={{light_z}}")
|
||
print("总照明功率: 180W")
|
||
print("灯光类型: 点光源(软衰减+阴影)")
|
||
print(f"衰减类型: 平方反比衰减")
|
||
print(f"阴影设置: 启用,柔和阴影半径0.5米")
|
||
print(f"灯光高度: {{light_height}}米 (房间高度减一米)")
|
||
|
||
except Exception as e:
|
||
print(f"创建点光源时出错: {{e}}")
|
||
print("将使用默认的ISO Emission灯光")
|
||
|
||
print("照明设置完成")
|
||
|
||
def render_scene():
|
||
"""渲染场景"""
|
||
print("开始渲染...")
|
||
bpy.ops.render.render(write_still=True)
|
||
print(f"渲染完成!")
|
||
return bpy.context.scene.render.filepath
|
||
|
||
def main():
|
||
"""主函数"""
|
||
print("=" * 60)
|
||
print("开始API渲染任务")
|
||
print("=" * 60)
|
||
|
||
try:
|
||
# 1. 创建房间
|
||
print("1. 创建等轴测房间...")
|
||
if not create_isometric_room():
|
||
print("房间创建失败,停止执行")
|
||
return False
|
||
|
||
# 2. 设置渲染参数
|
||
print("2. 设置渲染参数...")
|
||
output_path = setup_render_settings()
|
||
|
||
# 3. 设置摄像机和照明
|
||
print("3. 设置摄像机和照明...")
|
||
setup_camera_and_lighting()
|
||
|
||
# 4. 渲染场景
|
||
print("4. 开始渲染...")
|
||
final_path = render_scene()
|
||
|
||
print("=" * 60)
|
||
print("API渲染任务完成!")
|
||
print(f"输出文件: {{final_path}}")
|
||
print("=" * 60)
|
||
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"执行过程中出现错误: {{str(e)}}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return False
|
||
|
||
# 执行主函数
|
||
if __name__ == "__main__":
|
||
main()
|
||
'''
|
||
|
||
return script.strip(), output_file
|
||
|
||
async def render_room(self, task_id: str, request: RenderRequest) -> str:
|
||
"""
|
||
执行房间渲染
|
||
|
||
Args:
|
||
task_id: 任务ID
|
||
request: 渲染请求参数
|
||
|
||
Returns:
|
||
输出文件路径
|
||
"""
|
||
try:
|
||
# 生成Blender脚本
|
||
script_content, output_file = self._generate_blender_script(
|
||
task_id, request)
|
||
|
||
# 创建临时脚本文件
|
||
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
|
||
f.write(script_content)
|
||
script_path = f.name
|
||
|
||
try:
|
||
# 构建Blender命令
|
||
cmd = [
|
||
self.blender_path,
|
||
"--background",
|
||
"--disable-crash-handler",
|
||
"--python", script_path
|
||
]
|
||
|
||
logger.info(f"执行Blender命令: {' '.join(cmd)}")
|
||
|
||
# 执行Blender渲染
|
||
process = await asyncio.create_subprocess_exec(
|
||
*cmd,
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=asyncio.subprocess.PIPE
|
||
)
|
||
|
||
stdout, stderr = await asyncio.wait_for(
|
||
process.communicate(),
|
||
timeout=300 # 5分钟超时
|
||
)
|
||
|
||
# 检查执行结果
|
||
if process.returncode == 0:
|
||
if os.path.exists(output_file):
|
||
logger.info(f"渲染成功: {output_file}")
|
||
return output_file
|
||
else:
|
||
raise Exception("渲染完成但输出文件不存在")
|
||
else:
|
||
error_msg = stderr.decode('utf-8') if stderr else "未知错误"
|
||
raise Exception(
|
||
f"Blender执行失败 (返回码: {process.returncode}): {error_msg}")
|
||
|
||
finally:
|
||
# 清理临时脚本文件
|
||
try:
|
||
os.unlink(script_path)
|
||
except:
|
||
pass
|
||
|
||
except asyncio.TimeoutError:
|
||
raise Exception("渲染超时(5分钟)")
|
||
except Exception as e:
|
||
logger.error(f"渲染失败: {e}")
|
||
raise
|