역운동학

Inverse Kinematics

끝 관절의 목표 위치를 정하면 블렌더가 연결된 위쪽 관절의 회전을 역으로 계산하는 제약.

다른 이름: IKIK SolverKinematicConstraint
···
bpy — 이 항목
# IK influence 0·1에서 평가된 본 위치를 렌더용 막대로 그려 비교한다.
reset('WORKBENCH')
camera((2.0, -6.0, 8.8), target=(0.5, 0, 1.7), lens=52)
floor()

data = bpy.data.armatures.new('IK Armature')
rig = bpy.data.objects.new('IK Armature', data)
bpy.context.scene.collection.objects.link(rig)
bpy.context.view_layer.objects.active = rig
rig.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
previous = None
for name, head, tail in [
    ('upper', (0, 0, 0.35), (0, 0, 1.35)),
    ('lower', (0, 0, 1.35), (0, 0, 2.35)),
    ('hand', (0, 0, 2.35), (0, 0, 3.25)),
]:
    bone = data.edit_bones.new(name)
    bone.head, bone.tail = head, tail
    bone.parent = previous
    bone.use_connect = previous is not None
    previous = bone
bpy.ops.object.mode_set(mode='OBJECT')
rig.show_in_front = True

target = bpy.data.objects.new('IK target', None)
bpy.context.scene.collection.objects.link(target)
target.location = (1.45, 0, 1.85)
target.empty_display_type = 'SPHERE'
target.empty_display_size = 0.18
bpy.ops.mesh.primitive_uv_sphere_add(segments=20, ring_count=12, radius=0.18, location=target.location)
goal = bpy.context.object
goal.name = 'Target marker'
paint(goal, material('Target', LIME))

constraint = rig.pose.bones['hand'].constraints.new('IK')
constraint.target = target
constraint.chain_count = 3
constraint.use_tail = True
constraint.influence = 0.0

rods = {}
for name, color in [('upper', CLAY), ('lower', SKY), ('hand', CORAL)]:
    bpy.ops.mesh.primitive_cylinder_add(vertices=20, radius=0.14, depth=1)
    rod = bpy.context.object
    rod.name = name + ' · render proxy'
    paint(rod, material(name, color))
    rods[name] = rod

def sync_chain():
    bpy.context.view_layer.update()
    evaluated = rig.evaluated_get(bpy.context.evaluated_depsgraph_get())
    for name, rod in rods.items():
        bone = evaluated.pose.bones[name]
        head = evaluated.matrix_world @ bone.matrix.translation
        tail = evaluated.matrix_world @ (bone.matrix @ Vector((0, bone.length, 0)))
        direction = tail - head
        rod.location = (head + tail) * 0.5
        rod.rotation_mode = 'QUATERNION'
        rod.rotation_quaternion = direction.to_track_quat('Z', 'Y')
        rod.scale.z = direction.length

sync_chain()
frame(*rods.values(), goal, margin=1.45)
shoot('free')
constraint.influence = 1.0
sync_chain()
shoot('reach')
전체 스크립트 — Blender 5.1.1 에서 그대로 실행
# inverse-kinematics — Blender 5.1.1 에서 그대로 돌아가는 스크립트
# 실행: blender -b --factory-startup --python inverse-kinematics.py -- <출력 폴더>
SLUG = 'inverse-kinematics'

# ── 무대: 모든 항목이 같이 쓰는 준비 코드 (renders/_stage.py) ──────────────────
# 빈 장면 → 렌더 엔진·크기 → 카메라·바닥·조명·재질 도우미 → shoot() 으로 WebP 저장.
import bpy, math, os, sys
from mathutils import Vector

# 출력 폴더: blender -b --factory-startup --python 이파일.py -- <출력 폴더>
OUT = sys.argv[sys.argv.index('--') + 1] if '--' in sys.argv[:-1] else os.getcwd()
os.makedirs(OUT, exist_ok=True)

# 색은 화면에서 고르는 sRGB 0~1 값으로 적는다. 블렌더 내부는 선형(linear)이라 srgb() 로 바꿔 넣는다.
BACKDROP = (0.24, 0.25, 0.27)
CLAY = (0.80, 0.77, 0.72)
LIME = (0.64, 0.90, 0.21)
SKY = (0.22, 0.65, 0.95)
CORAL = (0.96, 0.42, 0.45)
INK = (0.08, 0.09, 0.10)
ENGINES = {'WORKBENCH': 'BLENDER_WORKBENCH', 'EEVEE': 'BLENDER_EEVEE', 'CYCLES': 'CYCLES'}


def srgb(c, alpha=1.0):
    lin = lambda v: v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4
    return (lin(c[0]), lin(c[1]), lin(c[2]), alpha)


def reset(engine='WORKBENCH', samples=16, size=(960, 600)):
    """빈 장면에서 시작한다. engine: WORKBENCH(모양 확인) · EEVEE(실시간) · CYCLES(경로 추적)."""
    bpy.ops.wm.read_factory_settings(use_empty=True)
    sc = bpy.context.scene
    sc.render.engine = ENGINES[engine]
    sc.render.resolution_x, sc.render.resolution_y = size
    sc.render.resolution_percentage = 100
    sc.render.image_settings.file_format = 'WEBP'
    sc.render.image_settings.quality = 82
    sc.world = bpy.data.worlds.new('World')
    world_color(BACKDROP)
    if engine == 'WORKBENCH':
        sh = sc.display.shading
        sh.light, sh.color_type = 'STUDIO', 'MATERIAL'
        sh.show_shadows, sh.shadow_intensity = True, 0.35
        sh.show_cavity = True
        sc.display.render_aa = '16'
        sc.view_settings.view_transform = 'Standard'  # 재질 색을 그대로 보여 준다
    elif engine == 'EEVEE':
        sc.eevee.taa_render_samples = samples
    else:
        sc.cycles.samples = samples
        sc.cycles.use_denoising = True
    return sc


def world_color(color, strength=1.0):
    """배경색. Workbench 는 world.color, EEVEE·Cycles 는 Background 노드를 쓴다."""
    w = bpy.context.scene.world
    w.color = srgb(color)[:3]
    bg = w.node_tree.nodes.get('Background') if w.node_tree else None
    if bg:
        bg.inputs['Color'].default_value = srgb(color)
        bg.inputs['Strength'].default_value = strength
    return w


def hdri(name='studio', strength=1.0, show=False):
    """블렌더에 들어 있는 HDRI 로 장면을 비춘다(CC0 · Poly Haven). name: studio · city · courtyard · forest · interior · night · sunrise · sunset.
    show=False 면 빛과 반사에만 쓰고, 카메라에는 BACKDROP 색 배경이 보인다(Light Path 의 Is Camera Ray 로 가른다)."""
    nt = bpy.context.scene.world.node_tree
    nodes, links = nt.nodes, nt.links
    env = nodes.new('ShaderNodeTexEnvironment')
    env.image = bpy.data.images.load(os.path.join(bpy.utils.system_resource('DATAFILES'), 'studiolights', 'world', f'{name}.exr'))
    bg = nodes['Background']
    bg.inputs['Strength'].default_value = strength
    links.new(env.outputs['Color'], bg.inputs['Color'])
    if not show:
        flat = nodes.new('ShaderNodeBackground')
        flat.inputs['Color'].default_value = srgb(BACKDROP)
        ray, mix = nodes.new('ShaderNodeLightPath'), nodes.new('ShaderNodeMixShader')
        links.new(ray.outputs['Is Camera Ray'], mix.inputs['Fac'])
        links.new(bg.outputs['Background'], mix.inputs[1])
        links.new(flat.outputs['Background'], mix.inputs[2])
        links.new(mix.outputs['Shader'], nodes['World Output'].inputs['Surface'])
    return env


def look_at(obj, target):
    obj.rotation_euler = (Vector(target) - obj.location).to_track_quat('-Z', 'Y').to_euler()


def camera(location=(7, -7, 5), target=(0, 0, 0.5), lens=50):
    cam = bpy.data.objects.new('Camera', bpy.data.cameras.new('Camera'))
    bpy.context.scene.collection.objects.link(cam)
    cam.location = location
    look_at(cam, target)
    cam.data.lens = lens
    bpy.context.scene.camera = cam
    return cam


def frame(*objs, margin=1.15):
    """카메라 방향은 그대로 두고, objs 가 화면에 다 들어오게 카메라를 앞뒤로 옮긴다."""
    sc = bpy.context.scene
    dg = bpy.context.evaluated_depsgraph_get()
    pts = [o.matrix_world @ Vector(c) for o in objs for c in o.evaluated_get(dg).bound_box]
    mid = sum(pts, Vector()) / len(pts)
    coords = [c for p in pts for c in (mid + (p - mid) * margin)]
    loc, _scale = sc.camera.camera_fit_coords(dg, coords)
    sc.camera.location = loc
    return sc.camera


def light(kind='AREA', location=(4, -4, 6), target=(0, 0, 0), energy=600, size=3.0, color=(1, 1, 1)):
    """kind: POINT · SUN · SPOT · AREA. energy 는 와트(SUN 은 W/m²에 해당하는 세기)."""
    data = bpy.data.lights.new(kind.title(), kind)
    data.energy, data.color = energy, color
    if kind == 'AREA':
        data.size = size
    obj = bpy.data.objects.new(data.name, data)
    bpy.context.scene.collection.objects.link(obj)
    obj.location = location
    look_at(obj, target)
    return obj


def studio(key=700, fill=200, rim=450):
    """기본 3점 조명(키·필·림). EEVEE·Cycles 용 — Workbench 는 자체 스튜디오 조명을 쓴다."""
    return [light('AREA', (5, -4, 6), energy=key, size=4),
            light('AREA', (-6, -3, 3), energy=fill, size=5),
            light('AREA', (0, 6, 5), energy=rim, size=3)]


def material(name, color=CLAY, roughness=0.5, metallic=0.0, **inputs):
    """원리 BSDF 재질. inputs 는 소켓 이름(공백은 _ 로): Emission_Strength=3."""
    mat = bpy.data.materials.new(name)
    mat.diffuse_color = srgb(color)  # Workbench 가 보는 색
    bsdf = mat.node_tree.nodes['Principled BSDF']
    bsdf.inputs['Base Color'].default_value = srgb(color)
    bsdf.inputs['Roughness'].default_value = roughness
    bsdf.inputs['Metallic'].default_value = metallic
    for key, value in inputs.items():
        bsdf.inputs[key.replace('_', ' ')].default_value = value
    return mat


def paint(obj, mat):
    obj.data.materials.clear()
    obj.data.materials.append(mat)
    return obj


def floor(size=80.0, color=None, z=0.0, wall=30.0, curve=6.0):
    """바닥과 뒷벽(+Y 쪽)이 둥글게 이어진 스튜디오 배경 — 지평선이 보이지 않는다. 카메라는 -Y 쪽에서 +Y 를 보게 둔다."""
    import bmesh
    back = size / 2
    steps = 12
    profile = [(-back, 0.0)]  # (y, z) 단면: 앞 → 둥근 모서리 → 벽 위
    for i in range(steps + 1):
        a = (math.pi / 2) * i / steps
        profile.append((back - curve + curve * math.sin(a), curve - curve * math.cos(a)))
    profile.append((back, wall))
    bm = bmesh.new()
    left = [bm.verts.new((-back, y, z + h)) for y, h in profile]
    right = [bm.verts.new((back, y, z + h)) for y, h in profile]
    for i in range(len(profile) - 1):
        bm.faces.new((left[i], right[i], right[i + 1], left[i + 1]))
    me = bpy.data.meshes.new('Floor')
    bm.to_mesh(me)
    bm.free()
    obj = bpy.data.objects.new('Floor', me)
    bpy.context.scene.collection.objects.link(obj)
    for poly in me.polygons:
        poly.use_smooth = True
    return paint(obj, material('Floor', color or BACKDROP, roughness=0.9))


def edges(obj, thickness=0.012, color=INK):
    """메시의 선(엣지)을 렌더에 보이게 한다 — 모디파이어까지 적용된 모양에 와이어프레임을 씌운 복제본."""
    dg = bpy.context.evaluated_depsgraph_get()
    wire = bpy.data.objects.new(obj.name + '.edges', bpy.data.meshes.new_from_object(obj.evaluated_get(dg)))
    bpy.context.scene.collection.objects.link(wire)
    wire.matrix_world = obj.matrix_world.copy()
    mod = wire.modifiers.new('Wireframe', 'WIREFRAME')
    mod.thickness, mod.use_even_offset = thickness, True
    return paint(wire, material(obj.name + '.edges', color, roughness=0.8))


def shoot(name):
    """지금 장면을 <출력 폴더>/<항목>-<name>.webp 로 렌더한다."""
    sc = bpy.context.scene
    sc.render.filepath = os.path.join(OUT, f'{SLUG}-{name}.webp')
    bpy.ops.render.render(write_still=True)
    return sc.render.filepath


# ── 이 항목 ──
# IK influence 0·1에서 평가된 본 위치를 렌더용 막대로 그려 비교한다.
reset('WORKBENCH')
camera((2.0, -6.0, 8.8), target=(0.5, 0, 1.7), lens=52)
floor()

data = bpy.data.armatures.new('IK Armature')
rig = bpy.data.objects.new('IK Armature', data)
bpy.context.scene.collection.objects.link(rig)
bpy.context.view_layer.objects.active = rig
rig.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
previous = None
for name, head, tail in [
    ('upper', (0, 0, 0.35), (0, 0, 1.35)),
    ('lower', (0, 0, 1.35), (0, 0, 2.35)),
    ('hand', (0, 0, 2.35), (0, 0, 3.25)),
]:
    bone = data.edit_bones.new(name)
    bone.head, bone.tail = head, tail
    bone.parent = previous
    bone.use_connect = previous is not None
    previous = bone
bpy.ops.object.mode_set(mode='OBJECT')
rig.show_in_front = True

target = bpy.data.objects.new('IK target', None)
bpy.context.scene.collection.objects.link(target)
target.location = (1.45, 0, 1.85)
target.empty_display_type = 'SPHERE'
target.empty_display_size = 0.18
bpy.ops.mesh.primitive_uv_sphere_add(segments=20, ring_count=12, radius=0.18, location=target.location)
goal = bpy.context.object
goal.name = 'Target marker'
paint(goal, material('Target', LIME))

constraint = rig.pose.bones['hand'].constraints.new('IK')
constraint.target = target
constraint.chain_count = 3
constraint.use_tail = True
constraint.influence = 0.0

rods = {}
for name, color in [('upper', CLAY), ('lower', SKY), ('hand', CORAL)]:
    bpy.ops.mesh.primitive_cylinder_add(vertices=20, radius=0.14, depth=1)
    rod = bpy.context.object
    rod.name = name + ' · render proxy'
    paint(rod, material(name, color))
    rods[name] = rod

def sync_chain():
    bpy.context.view_layer.update()
    evaluated = rig.evaluated_get(bpy.context.evaluated_depsgraph_get())
    for name, rod in rods.items():
        bone = evaluated.pose.bones[name]
        head = evaluated.matrix_world @ bone.matrix.translation
        tail = evaluated.matrix_world @ (bone.matrix @ Vector((0, bone.length, 0)))
        direction = tail - head
        rod.location = (head + tail) * 0.5
        rod.rotation_mode = 'QUATERNION'
        rod.rotation_quaternion = direction.to_track_quat('Z', 'Y')
        rod.scale.z = direction.length

sync_chain()
frame(*rods.values(), goal, margin=1.45)
shoot('free')
constraint.influence = 1.0
sync_chain()
shoot('reach')

역운동학(IK)은 손끝을 잡아 목표점으로 옮기면 팔꿈치와 어깨가 따라오는 방식이다. Pose Mode에서 끝 본을 선택하고 Bone Constraints ▸ Add Bone Constraint ▸ Inverse Kinematics를 고른다. Target 오브젝트와 Chain Length를 지정하면 어느 본까지 풀지 정할 수 있다.

코드에서는 끝 pose bone에 constraints.new('IK')를 만들고 constraint.target과 chain_count를 설정한다. 체인이 푸는 회전은 여러 해가 있을 수 있어 Pole Target이나 pole_angle로 팔꿈치가 굽는 방향을 고정한다. IK는 포즈 평가 제약이므로 bpy.ops보다 데이터 API로 설정하면 현재 선택 상태에 덜 의존한다.

목표가 체인 길이보다 멀면 끝점이 닿지 않고, 체인 길이를 너무 크게 잡으면 몸통까지 흔들릴 수 있다. 데모의 첫 장은 Target은 있지만 IK influence가 0이라 직선인 사슬이고, 다음 장은 influence를 1로 켜 목표를 향해 푼 사슬이다. 본은 직접 렌더되지 않아 평가된 본 위치를 막대 메시로 그려 비교한다.

언제 쓰나

손·발처럼 끝점을 먼저 맞춰야 하는 포즈에 쓴다. 목표점이 움직이는 장면에서 키프레임을 줄일 수 있지만, 팔꿈치·무릎 방향은 Pole Target으로 따로 제어한다.

페이지로 열기 ↗

항목 추가 요청

아틀라스에 없는 블렌더 용어·도구·작업 흐름을 알려 주세요. 하나씩 보고 추가합니다.