#!/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