DEV Community

Cover image for P7-Ch01. Keyframing_Time_Functions
BamgasiJM
BamgasiJM

Posted on

P7-Ch01. Keyframing_Time_Functions

시간 함수를 키프레임으로 기록하기

시간에 따라 변하는 함수를 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()
Enter fullscreen mode Exit fullscreen mode

키 샘플 간격 조절

KEYFRAME_STEP은 함수의 값을 몇 프레임 간격으로 기록할지 정합니다. 현재 설정에서는 프레임 1부터 121까지 네 프레임 간격으로 키를 만들며, 각 기둥에는 시작과 끝 경계 키를 포함해 31개의 키가 생깁니다. 끝 프레임이 간격과 맞지 않아도 sampled_frames()가 경계 키를 추가합니다.

간격을 1로 바꾸면 모든 프레임에 파동 함수의 값이 기록됩니다. 장면을 정수 프레임에서 재생하거나 출력할 때는 원래 함수를 더 직접적으로 따르지만 키 수가 크게 늘어납니다. 간격을 늘리면 Action 데이터는 작아지는 대신 키 사이 보간의 영향이 커집니다. 파동의 주기와 출력 프레임 레이트를 함께 고려해 샘플 간격을 정합니다.

CYCLE_COUNT를 정수로 두면 경계 프레임의 파동 위상이 맞습니다. 다만 같은 시작·끝 값만으로 반복 재생의 속도까지 보장되지는 않습니다. 부드럽게 이어지는 루프가 필요하면 F-Curve의 보간과 양 끝의 기울기도 함께 맞춰야 합니다.

Top comments (0)