irg/Isometquick-server/blender_service0716).py

593 lines
21 KiB
Python
Raw Permalink Normal View History

2025-07-18 16:42:22 +08:00
#!/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