시간 함수를 키프레임으로 기록하기
시간에 따라 변하는 함수를 Blender에서 재생하려면 함수의 값을 특정 프레임에 계산해 오브젝트 속성의 키프레임으로 저장할 수 있습니다. 이 예제는 격자에 놓인 기둥의 높이를 파동 함수로 계산하고, 각 기둥의 Z 스케일에 키를 기록합니다. 공간 좌표가 위상을 나누고 프레임이 시간에 해당합니다.
키프레임은 함수 자체를 저장하지 않습니다. 선택한 프레임에서 계산한 값이 저장되고, 키 사이의 값은 F-Curve 보간으로 결정됩니다. 따라서 샘플 간격은 계산한 시간 함수를 얼마나 충실하게 재현하는지에 영향을 줍니다.
대상 버전: Blender 5.2 이상
프레임을 경과 시간으로 환산
프레임 번호는 장면의 시간 설정에 따라 실제 시간과 연결됩니다. Blender의 유효 프레임 레이트는 scene.render.fps / scene.render.fps_base입니다. 시작 프레임을 $f_0$라 할 때 프레임 $f$까지의 경과 시간은 다음과 같습니다.
$
t(f) = \frac{f - f_0}{\mathrm{fps}/\mathrm{fps}_{\mathrm{base}}}
$
예제는 초당 24프레임으로 5초 동안 진행하는 두 주기의 파동을 만듭니다. 첫 키는 프레임 1, 파동의 끝 위상은 프레임 121에 기록합니다. 장면 출력 범위는 프레임 1부터 120까지로 두어 첫 포즈와 같은 경계 포즈를 마지막 출력 이미지에 중복하지 않습니다.
scene.frame_set(frame)은 현재 프레임을 바꾸고 애니메이션을 평가합니다. 코드 마지막에 시작 프레임을 지정해 두면 스크립트 실행 직후 첫 포즈를 확인할 수 있습니다.
공간 위상에 따라 달라지는 높이
각 기둥의 높이는 위치 $(x,y)$와 경과 시간 $t$로 계산합니다. $k_x$와 $k_y$는 장면 좌표 1단위당 위상 변화량, $C$는 전체 구간의 주기 수, $T$는 구간의 길이입니다.
$$
h(x,y,t) = h_{\min} + (h_{\max}-h_{\min})\frac{1+\sin(\omega t-k_x x-k_y y)}{2},
\qquad \omega = \frac{2\pi C}{T}
$$
사인 함수의 결과를 0과 1 사이로 옮긴 뒤 높이 범위에 대응합니다. $k_x x+k_y y$가 위치마다 다른 위상을 만들고, $\omega t$가 모든 기둥의 위상을 시간에 따라 진행시킵니다. 식에서 시간 항과 공간 항을 빼는 방식은 파동의 진행 방향을 정합니다. $C$가 정수이면 시작 프레임과 끝 경계 프레임에서 파동의 값이 반복됩니다.
기둥 메시의 로컬 Z 좌표를 0부터 1까지 만들고 오브젝트 원점을 바닥에 둡니다. 그러면 scale.z를 높이로 사용해도 밑면은 바닥에 고정됩니다. 각 오브젝트는 Mesh 데이터를 공유하지만 변환과 애니메이션 데이터는 독립적입니다.
함수 샘플을 키프레임으로 기록
obj.keyframe_insert(data_path="scale", index=2, frame=frame)은 지정한 프레임의 Z 스케일을 키로 저장합니다. index=2는 세 성분 중 Z 채널만 대상으로 합니다. X·Y 스케일과 위치는 고정되어 있으므로 애니메이션 데이터에 포함되지 않습니다.
예제는 네 프레임 간격으로 높이를 계산하고, 끝 경계 프레임이 간격에 포함되지 않더라도 마지막에 추가합니다. 기본 F-Curve 보간은 Bezier이므로 키 사이의 높이는 두 샘플을 부드럽게 잇는 값입니다. 이 값은 원래 파동 함수를 매 프레임 직접 계산한 결과와 완전히 같지는 않습니다.
키 샘플을 촘촘히 기록할수록 함수의 곡선을 더 세밀하게 따라갑니다. 반대로 간격을 넓히면 키 수는 줄지만 파동의 꼭대기나 골이 둥글게 깎이거나 급격한 변화가 덜 표현될 수 있습니다. 보간 방식을 바꾸고 경계에서 속도를 맞추는 방법은 F-Curve를 직접 다루는 장에서 살펴봅니다.
전체 코드: 위상차가 있는 기둥 웨이브
Object Mode에서 실행합니다. 스크립트가 전용 컬렉션에 공유 Mesh를 사용하는 기둥 군집을 만들고, 각 오브젝트의 Z 스케일에 파동 높이를 키프레임으로 기록합니다. 다시 실행하면 이 스크립트가 만든 오브젝트와 사용하지 않는 애니메이션 데이터만 정리합니다.
import bpy import math SCRIPT_ID = "BlenderPython.P7Ch01" COLLECTION_NAME = "P7_Ch01_Keyframed_Wave" MESH_NAME = "P7_Ch01_Unit_Column_Mesh" GRID_COLUMNS = 7 GRID_ROWS = 7 GRID_SPACING = 0.72 COLUMN_WIDTH = 0.34 COLUMN_DEPTH = 0.34 MIN_HEIGHT = 0.24 MAX_HEIGHT = 2.15 WAVE_KX = 0.82 WAVE_KY = 0.47 CYCLE_COUNT = 2 FRAME_START = 1 FRAME_INTERVALS = 120 KEY_END_FRAME = FRAME_START + FRAME_INTERVALS KEYFRAME_STEP = 4 FPS = 24 FPS_BASE = 1.0 def validate_settings(): if bpy.app.version < (5, 2, 0): raise RuntimeError("Blender 5.2 이상에서 실행하세요.") if bpy.context.mode != "OBJECT": raise RuntimeError("Object Mode에서 실행하세요.") counts = ( GRID_COLUMNS, GRID_ROWS, FRAME_INTERVALS, KEYFRAME_STEP, FPS, CYCLE_COUNT, ) if any( isinstance(value, bool) or not isinstance(value, int) for value in counts ): raise ValueError("격자·프레임·주기 설정에는 정수를 사용하세요.") if min(GRID_COLUMNS, GRID_ROWS, FRAME_INTERVALS, KEYFRAME_STEP, FPS) < 1: raise ValueError("격자 크기와 시간 설정은 양수여야 합니다.") if CYCLE_COUNT < 1 or FPS_BASE <= 0.0: raise ValueError("주기 수와 FPS_BASE를 확인하세요.") if not all(math.isfinite(value) for value in ( GRID_SPACING, COLUMN_WIDTH, COLUMN_DEPTH, MIN_HEIGHT, MAX_HEIGHT, WAVE_KX, WAVE_KY, FPS_BASE, )): raise ValueError("크기·파동·프레임 설정에는 유한한 수를 사용하세요.") if min(GRID_SPACING, COLUMN_WIDTH, COLUMN_DEPTH, MIN_HEIGHT) <= 0.0: raise ValueError("간격·기둥 크기·최소 높이는 양수여야 합니다.") if MAX_HEIGHT <= MIN_HEIGHT: raise ValueError("MAX_HEIGHT는 MIN_HEIGHT보다 커야 합니다.") def get_output_collection(): collection = bpy.data.collections.get(COLLECTION_NAME) if collection is None: collection = bpy.data.collections.new(COLLECTION_NAME) scene_root = bpy.context.scene.collection if scene_root.children.get(collection.name) is None: scene_root.children.link(collection) return collection def unlink_or_remove_object(collection, obj): other_collections = [ owner for owner in obj.users_collection if owner.as_pointer() != collection.as_pointer() ] if other_collections: collection.objects.unlink(obj) else: bpy.data.objects.remove(obj, do_unlink=True) def clear_previous_output(collection): owned_objects = [ obj for obj in list(collection.objects) if obj.get("script_id") == SCRIPT_ID ] actions = [] for obj in owned_objects: animation_data = obj.animation_data if animation_data is None or animation_data.action is None: continue action = animation_data.action if all( existing.as_pointer() != action.as_pointer() for existing in actions ): actions.append(action) for obj in owned_objects: unlink_or_remove_object(collection, obj) for action in actions: if action.users == 0: bpy.data.actions.remove(action) for mesh in list(bpy.data.meshes): if mesh.name == MESH_NAME and mesh.users == 0: bpy.data.meshes.remove(mesh) def make_column_mesh(): vertices = ( (-0.5, -0.5, 0.0), ( 0.5, -0.5, 0.0), ( 0.5, 0.5, 0.0), (-0.5, 0.5, 0.0), (-0.5, -0.5, 1.0), ( 0.5, -0.5, 1.0), ( 0.5, 0.5, 1.0), (-0.5, 0.5, 1.0), ) faces = ( (3, 2, 1, 0), (4, 5, 6, 7), (0, 1, 5, 4), (1, 2, 6, 5), (2, 3, 7, 6), (3, 0, 4, 7), ) mesh = bpy.data.meshes.new(MESH_NAME) mesh.from_pydata(vertices, [], faces) mesh.update() return mesh def configure_timeline(scene): scene.render.fps = FPS scene.render.fps_base = FPS_BASE scene.frame_start = FRAME_START # KEY_END_FRAME는 첫 프레임과 같은 위상의 경계 키입니다. scene.frame_end = KEY_END_FRAME - 1 def frame_to_seconds(frame, scene): frames_per_second = scene.render.fps / scene.render.fps_base return (frame - FRAME_START) / frames_per_second def animation_duration_seconds(scene): frames_per_second = scene.render.fps / scene.render.fps_base return FRAME_INTERVALS / frames_per_second def wave_height(x, y, frame, scene): elapsed = frame_to_seconds(frame, scene) progress = elapsed / animation_duration_seconds(scene) spatial_phase = WAVE_KX * x + WAVE_KY * y phase = math.tau * CYCLE_COUNT * progress - spatial_phase normalized_height = 0.5 + 0.5 * math.sin(phase) return MIN_HEIGHT + (MAX_HEIGHT - MIN_HEIGHT) * normalized_height def sampled_frames(): frames = list(range( FRAME_START, KEY_END_FRAME + 1, KEYFRAME_STEP, )) if frames[-1] != KEY_END_FRAME: frames.append(KEY_END_FRAME) return frames def make_column(row, column, mesh, collection, scene, frames): name = f"P7_Ch01_Column_{row:02d}_{column:02d}" obj = bpy.data.objects.new(name, mesh) obj["script_id"] = SCRIPT_ID collection.objects.link(obj) x = (column - (GRID_COLUMNS - 1) * 0.5) * GRID_SPACING y = (row - (GRID_ROWS - 1) * 0.5) * GRID_SPACING obj.location = (x, y, 0.0) obj.scale = (COLUMN_WIDTH, COLUMN_DEPTH, MIN_HEIGHT) for frame in frames: obj.scale.z = wave_height(x, y, frame, scene) obj.keyframe_insert( data_path="scale", index=2, frame=frame, group="Wave Height", ) return obj def main(): validate_settings() scene = bpy.context.scene configure_timeline(scene) collection = get_output_collection() clear_previous_output(collection) column_mesh = make_column_mesh() frames = sampled_frames() columns = [] for row in range(GRID_ROWS): for column in range(GRID_COLUMNS): obj = make_column( row, column, column_mesh, collection, scene, frames, ) columns.append(obj) scene.frame_set(FRAME_START) print( "기둥 수:", len(columns), "| 키 샘플 수/기둥:", len(frames), "| 유효 FPS:", scene.render.fps / scene.render.fps_base, "| 구간 길이(초):", animation_duration_seconds(scene), ) return columns if __name__ == "__main__": animated_columns = main() 키 샘플 간격 조절
KEYFRAME_STEP은 함수의 값을 몇 프레임 간격으로 기록할지 정합니다. 현재 설정에서는 프레임 1부터 121까지 네 프레임 간격으로 키를 만들며, 각 기둥에는 시작과 끝 경계 키를 포함해 31개의 키가 생깁니다. 끝 프레임이 간격과 맞지 않아도 sampled_frames()가 경계 키를 추가합니다.
간격을 1로 바꾸면 모든 프레임에 파동 함수의 값이 기록됩니다. 장면을 정수 프레임에서 재생하거나 출력할 때는 원래 함수를 더 직접적으로 따르지만 키 수가 크게 늘어납니다. 간격을 늘리면 Action 데이터는 작아지는 대신 키 사이 보간의 영향이 커집니다. 파동의 주기와 출력 프레임 레이트를 함께 고려해 샘플 간격을 정합니다.
CYCLE_COUNT를 정수로 두면 경계 프레임의 파동 위상이 맞습니다. 다만 같은 시작·끝 값만으로 반복 재생의 속도까지 보장되지는 않습니다. 부드럽게 이어지는 루프가 필요하면 F-Curve의 보간과 양 끝의 기울기도 함께 맞춰야 합니다.


