# Boids Simulation for Blender 4.5.3 - With Random Scale
bl_info = {
"name": "Boids Simulation (With Random Scale)",
"author": "AI Assistant",
"version": (2, 3),
"blender": (4, 5, 3),
"location": "View3D > Sidebar > Boids Tab",
"description": "Flocking simulation with per-instance random scale.",
"category": "Animation",
}
import bpy
import numpy as np
from scipy.spatial.distance import squareform, pdist
from mathutils import Vector
from bpy.props import FloatProperty, IntProperty, PointerProperty
# 全局变量
boid_objects = [] # 实例对象列表
is_running = False # 是否实时运行模拟
sim = None # 当前模拟器实例
baked_frames = [] # 记录已烘焙的帧范围 [(start, end), ...]
class BoidsSimulation:
def __init__(self, N, template_obj, bounds, center_obj=None):
self.N = N
self.template_obj = template_obj
self.width, self.height, self.depth = bounds
self.center_obj = center_obj # 可选:控制体积位置的对象
# 初始化位置与速度
self.pos = np.random.uniform(
low=[-self.width/2, -self.height/2, -self.depth/2],
high=[self.width/2, self.height/2, self.depth/2],
size=(N, 3)
)
angles_xy = 2 * np.pi * np.random.rand(N)
angles_z = np.pi * np.random.rand(N) - np.pi / 2
self.vel = np.array([
np.cos(angles_z) * np.cos(angles_xy),
np.cos(angles_z) * np.sin(angles_xy),
np.sin(angles_z)
]).T
self.vel *= 1.0
# 可调参数
self.minDist = 3.0
self.maxDist = 8.0
self.separation_weight = 1.0
self.alignment_weight = 1.0
self.cohesion_weight = 1.0
self.max_speed = 0.5
self.noise_strength = 0.1
self.boundary_damping = 0.95
self.collision_distance = 1.5
self.scale_randomness = 0.0 # 默认不随机缩放
def get_center_offset(self):
"""返回当前中心对象的世界位置偏移"""
if self.center_obj and self.center_obj.name in bpy.data.objects:
return Vector(self.center_obj.location)
return Vector((0, 0, 0))
def step(self):
N = self.N
pos = self.pos.copy()
vel = self.vel.copy()
# 获取当前中心偏移
center_offset = np.array(self.get_center_offset())
world_pos = pos + center_offset
dist_matrix = squareform(pdist(world_pos))
sep_vel = np.zeros_like(vel)
ali_vel = np.zeros_like(vel)
coh_vel = np.zeros_like(vel)
# Rule 1: Separation
close_neighbors = dist_matrix < self.minDist
np.fill_diagonal(close_neighbors, False)
for i in range(N):
neighbors = close_neighbors[i]
if neighbors.any():
avg_world_pos = np.mean(world_pos[neighbors], axis=0)
sep_vel[i] = world_pos[i] - avg_world_pos
# Rule 2: Alignment
align_mask = (dist_matrix < self.maxDist) & (dist_matrix >= self.minDist)
np.fill_diagonal(align_mask, False)
for i in range(N):
neighbors = align_mask[i]
if neighbors.any():
avg_vel = np.mean(vel[neighbors], axis=0)
ali_vel[i] = avg_vel
# Rule 3: Cohesion
cohes_mask = (dist_matrix < self.maxDist) & (dist_matrix >= self.minDist)
np.fill_diagonal(cohes_mask, False)
for i in range(N):
neighbors = cohes_mask[i]
if neighbors.any():
center_world = np.mean(world_pos[neighbors], axis=0)
coh_vel[i] = center_world - world_pos[i]
def safe_normalize(v, eps=1e-6):
norm = np.linalg.norm(v)
return v / norm if norm > eps else np.zeros(3)
total_delta = np.zeros_like(vel)
if self.separation_weight > 0:
sep_vel = np.array([safe_normalize(v) for v in sep_vel]) * self.separation_weight
total_delta += sep_vel
if self.alignment_weight > 0:
ali_vel = np.array([safe_normalize(v) for v in ali_vel]) * self.alignment_weight
total_delta += ali_vel
if self.cohesion_weight > 0:
coh_vel = np.array([safe_normalize(v) for v in coh_vel]) * self.cohesion_weight
total_delta += coh_vel
noise = (np.random.rand(N, 3) - 0.5) * self.noise_strength
total_delta += noise
vel += total_delta
speeds = np.linalg.norm(vel, axis=1)
too_fast = speeds > self.max_speed
if too_fast.any():
vel[too_fast] = (vel[too_fast].T / speeds[too_fast] * self.max_speed).T
pos += vel
# 边界限制
current_center = self.get_center_offset()
x_min, x_max = current_center.x - self.width / 2, current_center.x + self.width / 2
y_min, y_max = current_center.y - self.height / 2, current_center.y + self.height / 2
z_min, z_max = current_center.z - self.depth / 2, current_center.z + self.depth / 2
damping = self.boundary_damping
x_low = pos[:, 0] + current_center.x < x_min
x_high = pos[:, 0] + current_center.x > x_max
vel[x_low | x_high, 0] *= -damping
y_low = pos[:, 1] + current_center.y < y_min
y_high = pos[:, 1] + current_center.y > y_max
vel[y_low | y_high, 1] *= -damping
z_low = pos[:, 2] + current_center.z < z_min
z_high = pos[:, 2] + current_center.z > z_max
vel[z_low | z_high, 2] *= -damping
pos[:, 0] = np.clip(pos[:, 0], -self.width/2, self.width/2)
pos[:, 1] = np.clip(pos[:, 1], -self.height/2, self.height/2)
pos[:, 2] = np.clip(pos[:, 2], -self.depth/2, self.depth/2)
self.pos = pos
self.vel = vel
def create_boid_collection():
coll_name = "Boids"
if coll_name not in bpy.data.collections:
coll = bpy.data.collections.new(coll_name)
bpy.context.scene.collection.children.link(coll)
return bpy.data.collections[coll_name]
def initialize_simulation():
global sim, boid_objects
scene = bpy.context.scene
props = scene.boids_props
template_obj = props.template_object
if not template_obj or template_obj.type != 'MESH':
raise Exception("请先选择一个有效的网格对象作为模板")
count = max(1, props.count)
width = props.sim_width
height = props.sim_height
depth = props.sim_depth
clear_simulation()
coll = create_boid_collection()
mesh = template_obj.data
# 预生成随机缩放值
scale_factor = props.random_scale_factor
scales = 1.0 + (np.random.rand(count) - 0.5) * 2 * scale_factor # 范围: [1-scale_factor, 1+scale_factor]
for i in range(count):
obj_name = f"Boid_{i:03d}"
obj = bpy.data.objects.new(obj_name, mesh)
obj.location = (0, 0, 0)
obj.rotation_mode = 'QUATERNION'
obj.rotation_quaternion.identity()
obj.scale = (scales[i], scales[i], scales[i]) # 均匀缩放
obj.data.materials.clear()
for mat in template_obj.data.materials:
obj.data.materials.append(mat)
coll.objects.link(obj)
boid_objects.append(obj)
bounds = (width, height, depth)
center_obj = props.center_object
sim = BoidsSimulation(N=count, template_obj=template_obj, bounds=bounds, center_obj=center_obj)
sim.scale_randomness = scale_factor # 存储用于调试或扩展
print(f"✅ 已创建 {count} 个 '{template_obj.name}' 的实例,带随机缩放 [1±{scale_factor:.2f}]")
print(f" 缩放范围: {1-scale_factor:.2f} ~ {1+scale_factor:.2f}")
def clear_simulation():
global boid_objects, sim
for obj in boid_objects:
if obj.name in bpy.data.objects:
bpy.data.objects.remove(obj, do_unlink=True)
boid_objects.clear()
sim = None
def update_boids(scene):
if not is_running or sim is None:
return
# 同步 UI 参数
props = scene.boids_props
sim.separation_weight = props.separation_weight
sim.alignment_weight = props.alignment_weight
sim.cohesion_weight = props.cohesion_weight
sim.max_speed = props.max_speed
sim.noise_strength = props.noise_strength
sim.minDist = props.min_distance
sim.maxDist = props.max_distance
sim.collision_distance = props.collision_distance
sim.center_obj = props.center_object
sim.step()
center_offset = sim.get_center_offset()
for i, obj in enumerate(boid_objects):
local_pos = sim.pos[i]
world_pos = Vector(local_pos) + center_offset
obj.location = world_pos
vel_vec = Vector(sim.vel[i])
if vel_vec.length > 0.01:
rot = vel_vec.to_track_quat('Z', 'Y')
obj.rotation_quaternion = rot
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
# ------------------------------------------------------------------------
# Operators
# ------------------------------------------------------------------------
class Boids_OT_Start(bpy.types.Operator):
bl_idname = "boids.start"
bl_label = "Start Simulation"
bl_options = {'REGISTER'}
def execute(self, context):
global is_running
try:
initialize_simulation()
is_running = True
if update_boids not in bpy.app.handlers.frame_change_pre:
bpy.app.handlers.frame_change_pre.append(update_boids)
if not context.screen.is_animation_playing:
bpy.ops.screen.animation_play()
except Exception as e:
self.report({'ERROR'}, str(e))
return {'CANCELLED'}
return {'FINISHED'}
class Boids_OT_Stop(bpy.types.Operator):
bl_idname = "boids.stop"
bl_label = "Stop Simulation"
def execute(self, context):
global is_running
is_running = False
return {'FINISHED'}
class Boids_OT_Clear(bpy.types.Operator):
bl_idname = "boids.clear"
bl_label = "Clear Simulation"
def execute(self, context):
clear_simulation()
global is_running
is_running = False
return {'FINISHED'}
class Boids_OT_Bake(bpy.types.Operator):
"""将当前模拟结果烘焙为关键帧"""
bl_idname = "boids.bake"
bl_label = "Bake Simulation"
def execute(self, context):
scene = context.scene
props = scene.boids_props
start_frame = props.bake_start_frame
end_frame = props.bake_end_frame
if not sim:
self.report({'ERROR'}, "请先点击 Start 初始化模拟")
return {'CANCELLED'}
old_frame = scene.frame_current
temp_sim = BoidsSimulation(
sim.N,
sim.template_obj,
(sim.width, sim.height, sim.depth),
center_obj=props.center_object
)
temp_sim.pos[:] = sim.pos.copy()
temp_sim.vel[:] = sim.vel.copy()
# 清除旧动画数据
for obj in boid_objects:
if obj.animation_data and obj.animation_data.action:
bpy.data.actions.remove(obj.animation_data.action)
obj.animation_data_create()
# 开始烘焙
for frame in range(start_frame, end_frame + 1):
scene.frame_set(frame)
temp_sim.separation_weight = props.separation_weight
temp_sim.alignment_weight = props.alignment_weight
temp_sim.cohesion_weight = props.cohesion_weight
temp_sim.max_speed = props.max_speed
temp_sim.noise_strength = props.noise_strength
temp_sim.minDist = props.min_distance
temp_sim.maxDist = props.max_distance
temp_sim.collision_distance = props.collision_distance
temp_sim.center_obj = props.center_object
temp_sim.step()
center_offset = temp_sim.get_center_offset()
for i, obj in enumerate(boid_objects):
world_pos = Vector(temp_sim.pos[i]) + center_offset
obj.location = world_pos
vel_vec = Vector(temp_sim.vel[i])
if vel_vec.length > 0.01:
rot = vel_vec.to_track_quat('Z', 'Y')
obj.rotation_quaternion = rot
obj.keyframe_insert(data_path="location", frame=frame)
obj.keyframe_insert(data_path="rotation_quaternion", frame=frame)
baked_frames.append((start_frame, end_frame))
scene.frame_set(old_frame)
self.report({'INFO'}, f"✅ 烘焙完成: 帧 {start_frame} 到 {end_frame}")
return {'FINISHED'}
class Boids_OT_ClearBake(bpy.types.Operator):
bl_idname = "boids.clear_bake"
bl_label = "Clear Bake Cache"
def execute(self, context):
for obj in boid_objects:
if obj.animation_data and obj.animation_data.action:
bpy.data.actions.remove(obj.animation_data.action)
obj.animation_data_clear()
baked_frames.clear()
self.report({'INFO'}, "🗑️ 已清除所有烘焙关键帧")
return {'FINISHED'}
class Boids_OT_ResetParams(bpy.types.Operator):
"""重置所有参数为默认值"""
bl_idname = "boids.reset_params"
bl_label = "Reset Parameters"
bl_description = "Reset all settings to their default values"
def execute(self, context):
props = context.scene.boids_props
# 恢复所有属性到默认值
props.template_object = None
props.center_object = None
props.count = 50
props.sim_width = 20.0
props.sim_height = 20.0
props.sim_depth = 20.0
props.min_distance = 3.0
props.max_distance = 8.0
props.collision_distance = 1.5
props.separation_weight = 1.0
props.alignment_weight = 1.0
props.cohesion_weight = 1.0
props.max_speed = 0.5
props.noise_strength = 0.1
props.bake_start_frame = 1
props.bake_end_frame = 250
props.random_scale_factor = 0.0 # 新增:重置随机缩放
self.report({'INFO'}, "🔄 参数已重置为默认值")
return {'FINISHED'}
# ------------------------------------------------------------------------
# Panel
# ------------------------------------------------------------------------
class Boids_PT_Panel(bpy.types.Panel):
bl_label = "Boids Simulation"
bl_idname = "VIEW3D_PT_boids"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "Boids"
def draw(self, context):
layout = self.layout
props = context.scene.boids_props
layout.label(text="Boid Template:")
layout.prop(props, "template_object", text="")
layout.separator()
layout.label(text="Center Object (Optional):")
layout.prop(props, "center_object", text="")
layout.label(text="Leave empty for origin-centered volume.")
layout.separator()
layout.label(text="Simulation Volume:")
row = layout.row(align=True)
row.prop(props, "sim_width", text="Width")
row = layout.row(align=True)
row.prop(props, "sim_height", text="Height")
row = layout.row(align=True)
row.prop(props, "sim_depth", text="Depth")
layout.separator()
layout.prop(props, "count")
layout.separator()
layout.prop(props, "random_scale_factor", text="Random Scale Factor") # 新增:随机缩放
layout.label(text="Scale range: [1±factor]", icon='INFO')
layout.separator()
layout.prop(props, "min_distance", text="Separation Min Dist")
layout.prop(props, "max_distance", text="Alignment/Cohesion Max Dist")
layout.prop(props, "collision_distance", text="Collision Distance")
layout.separator()
layout.label(text="Behavior Weights:")
layout.prop(props, "separation_weight", text="Separation")
layout.prop(props, "alignment_weight", text="Alignment")
layout.prop(props, "cohesion_weight", text="Cohesion")
layout.prop(props, "max_speed")
layout.prop(props, "noise_strength")
layout.separator()
layout.label(text="Baking Settings:")
row = layout.row()
row.prop(props, "bake_start_frame")
row.prop(props, "bake_end_frame")
layout.operator("boids.bake", icon='RENDER_STILL')
layout.operator("boids.clear_bake", icon='X')
layout.separator()
row = layout.row()
row.operator("boids.start", text="Start", icon='PLAY')
row.operator("boids.stop", text="Stop", icon='PAUSE')
layout.operator("boids.clear", text="Clear", icon='TRASH')
layout.separator()
layout.operator("boids.reset_params", text="Reset Parameters", icon='LOOP_BACK')
layout.separator()
status = "运行中" if is_running else "已停止"
icon = 'RENDER_STILL' if is_running else 'CANCEL'
layout.label(text=f"状态: {status}", icon=icon)
# ------------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------------
class BoidsProperties(bpy.types.PropertyGroup):
template_object: PointerProperty(
name="模板对象",
description="用作 boid 实例的 3D 对象",
type=bpy.types.Object,
poll=lambda self, obj: obj.type == 'MESH'
)
center_object: PointerProperty(
name="中心对象",
description="此对象定义模拟区域的中心位置。移动它可带动整个 flock 区域。",
type=bpy.types.Object
)
count: IntProperty(
name="数量",
description="生成的 boid 数量",
default=50,
min=1,
max=1000
)
sim_width: FloatProperty(name="宽度", default=20.0, min=1.0, max=100.0)
sim_height: FloatProperty(name="高度", default=20.0, min=1.0, max=100.0)
sim_depth: FloatProperty(name="深度", default=20.0, min=1.0, max=100.0)
random_scale_factor: FloatProperty(
name="随机缩放强度",
description="每个实例的缩放将在 [1 - factor, 1 + factor] 范围内随机",
default=0.0,
min=0.0,
max=1.0,
precision=2,
subtype='FACTOR'
)
min_distance: FloatProperty(
name="最小距离",
default=3.0,
min=0.1,
max=20.0,
options={'ANIMATABLE'}
)
max_distance: FloatProperty(
name="最大作用距离",
default=8.0,
min=0.1,
max=30.0,
options={'ANIMATABLE'}
)
collision_distance: FloatProperty(
name="碰撞距离",
description="当两个 boid 距离小于此值时视为发生碰撞",
default=1.5,
min=0.0,
max=10.0,
options={'ANIMATABLE'}
)
separation_weight: FloatProperty(
name="分离权重",
default=1.0,
min=0.0,
max=5.0,
options={'ANIMATABLE'}
)
alignment_weight: FloatProperty(
name="对齐权重",
default=1.0,
min=0.0,
max=5.0,
options={'ANIMATABLE'}
)
cohesion_weight: FloatProperty(
name="内聚权重",
default=1.0,
min=0.0,
max=5.0,
options={'ANIMATABLE'}
)
max_speed: FloatProperty(
name="最大速度",
default=0.5,
min=0.01,
max=5.0,
options={'ANIMATABLE'}
)
noise_strength: FloatProperty(
name="噪声强度",
default=0.1,
min=0.0,
max=1.0,
options={'ANIMATABLE'}
)
bake_start_frame: IntProperty(
name="烘焙起始帧",
default=1,
min=1,
max=10000
)
bake_end_frame: IntProperty(
name="烘焙结束帧",
default=250,
min=1,
max=10000
)
# ------------------------------------------------------------------------
# Registration
# ------------------------------------------------------------------------
_classes = (
BoidsProperties,
Boids_OT_Start,
Boids_OT_Stop,
Boids_OT_Clear,
Boids_OT_Bake,
Boids_OT_ClearBake,
Boids_OT_ResetParams,
Boids_PT_Panel,
)
def register():
for cls in _classes:
bpy.utils.register_class(cls)
bpy.types.Scene.boids_props = PointerProperty(type=BoidsProperties)
if update_boids not in bpy.app.handlers.frame_change_pre:
bpy.app.handlers.frame_change_pre.append(update_boids)
def unregister():
if update_boids in bpy.app.handlers.frame_change_pre:
bpy.app.handlers.frame_change_pre.remove(update_boids)
for cls in reversed(_classes):
bpy.utils.unregister_class(cls)
if hasattr(bpy.types.Scene, "boids_props"):
del bpy.types.Scene.boids_props
if __name__ == "__main__":
register()
修改以上代码,修改随机参数值限定在0到10之间,给我完整无误的代码。
最新发布