Blender AI Wiki / Русский Поиск

WF 17: STOP-MOTION FELT LOOK

RU: Stepped interpolation, fuzz texture, fingerprint normal EN: Stepped interpolation, fuzz texture, fingerprint normal Пластилиновый кукольный мультфильм: кадры «встают» вместо плавного движения, поверх материала торчит ворс, а вмятины от пальцев читаются в бликах. Три приёма дают 80 % узнаваемости

🔄 Воркфлоу ⭐⭐ полная 8 блоков кода обновлено 2026-11-12

ID: WF-17 | Type: Stylized | Difficulty: ⭐⭐ RU/EN Bilingual

🎯 ЦЕЛЬ / Goal

RU: Stepped interpolation, fuzz texture, fingerprint normal EN: Stepped interpolation, fuzz texture, fingerprint normal

Пластилиновый кукольный мультфильм: кадры «встают» вместо плавного движения, поверх материала торчит ворс, а вмятины от пальцев читаются в бликах. Три приёма дают 80 % узнаваемости, остальное — свет.

Пластилин в stop motion — это не «серый материал»: у него очень высокая шероховатость, сильный sheen по краям и лёгкое подповерхностное рассеяние. Ворс даёт мягкий ореол вокруг силуэта. Вмятины читаются только если свет скользящий — при frontal-свете их не видно.

📋 ЧТО ПОНАДОБИТСЯ

Компонент Версия Зачем
Blender 4.x Cycles, Hair-частицы
Модель скульпта GLB/FBX герой
Normal-карта вмятин 2k, снятая со скульпта отпечатки пальцев
HDRI «студия» 1k скользящий ключевой свет

🛠 НАСТРОЙКА

Этап 1. Тайминг: не 24 кадра в секунду, а позы

Стоп-моушен снимают на 12–15 кадров в секунду, и в каждом кадре поза держится. Реализуется это двумя настройками: рендерим через кадр, а интерполяция — CONSTANT.

import bpy

scene = bpy.context.scene
scene.render.fps = 24
scene.frame_start, scene.frame_end = 1, 72

# Каждый второй кадр => на выходе 12 fps. Это и есть «пластилиновый» темп.
scene.frame_step = 2

def set_constant_interpolation(objs):
    """Все ключи объектов -> CONSTANT: между позами нет перехода."""
    for ob in objs:
        ad = ob.animation_data
        if ad is None or ad.action is None:
            continue
        for fc in ad.action.fcurves:
            for kp in fc.keyframe_points:
                kp.interpolation = 'CONSTANT'
            fc.update()

set_constant_interpolation([o for o in bpy.context.scene.objects if o.animation_data])

[!NOTE] В Blender 4.4+ Actions стали многослойными (layered/slotted): action.fcurves может вернуть пустой список, потому что кривые лежат внутри layer → strip → channelbag слота. Если после вызова выше анимация всё ещё плавная — обойди дерево через action.layers[*].strips[*].channelbag(ad.action_slot).fcurves либо примени оператор из меню Animation вручную. Проверь структуру своего Action перед использованием функции в конвейере.

Этап 2. Пластилиновый материал

mat = bpy.data.materials.new("Mat_Felt")
mat.use_nodes = True
nt = mat.node_tree
p = next(n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED')

p.inputs["Base Color"].default_value = (0.72, 0.30, 0.22, 1.0)   # терракота
p.inputs["Roughness"].default_value = 0.92                      # почти мат, но не «мел»
p.inputs["Specular IOR Level"].default_value = 0.25             # минимум: пластик не «мокрый»
p.inputs["Sheen Weight"].default_value = 0.55                   # ореол по краям — ключ к «войлоку»
p.inputs["Sheen Roughness"].default_value = 0.25
p.inputs["Subsurface Weight"].default_value = 0.12              # тонкие уши/пальцы просвечивают
p.inputs["Subsurface Radius"].default_value = (0.02, 0.01, 0.008)
p.inputs["Subsurface Scale"].default_value = 0.01

# Мелкая зернистость: без неё пластилин выглядит как крашеный пластик
noise = nt.nodes.new("ShaderNodeTexNoise")
noise.inputs["Scale"].default_value = 340.0
noise.inputs["Detail"].default_value = 6.0
bump = nt.nodes.new("ShaderNodeBump")
bump.inputs["Strength"].default_value = 0.15
bump.inputs["Distance"].default_value = 0.002
nt.links.new(noise.outputs["Fac"], bump.inputs["Height"])
nt.links.new(bump.outputs["Normal"], p.inputs["Normal"])

[!NOTE] Названия сокетов Principled BSDF менялись в 4.0: Sheen стал Sheen Weight, Specular — Specular IOR Level, Subsurface — Subsurface Weight. В версиях до 4.0 использовать старые имена, иначе код упадёт на p.inputs["Sheen Weight"] с KeyError. Список актуальных имён: [s.name for s in p.inputs].

Этап 3. Ворс: hair-частицы с детьми

hero = bpy.data.objects["Hero"]
bpy.ops.object.particle_system_add()
ps = hero.particle_systems[-1]
st = ps.settings

st.type = 'HAIR'
st.count = 4000            # на объекте ~100k — уже тяжело
st.hair_length = 0.035     # ворс 3.5 см на фигуре 30 см
st.render_type = 'PATH'
st.use_advanced_hair = True

# Дети дают плотность без роста числа частиц
st.child_type = 'INTERPOLATED'
st.child_percent = 5        # % от родителей
st.rendered_child_count = 60
st.child_radius = 0.05
st.child_roundness = 2.0

st.kink = 'CURL'           # ворс завивается, как на войлочной игрушке
st.kink_amplitude = 0.004
st.kink_frequency = 3.0
st.root_radius = 0.0002
st.tip_radius = 0.00005     # кончик тоньше — силуэт «дымит»

Материал ворса: полупрозрачный Principled с Sheen, а не чёрный — иначе по краям появится грязная кайма.

fmat = bpy.data.materials.new("Mat_Fuzz")
fmat.use_nodes = True
fp = next(n for n in fmat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED')
fp.inputs["Base Color"].default_value = (0.85, 0.55, 0.45, 1.0)
fp.inputs["Roughness"].default_value = 0.9
fp.inputs["Sheen Weight"].default_value = 0.8
# Рендер с задержкой в несколько кадров: strand-ов очень много, лучше просчитать один раз
st.render_step = 3

Этап 4. Вмятины от пальцев

Отпечатки — это микрорельеф, а не цвет. Поэтому он делается либо нормаль-картой, либо впечатывается в геометрию.

# Вариант A: нормаль-карта со скульпта
tex = bpy.data.textures.new("Fingerprints", type='IMAGE')
tex.image = bpy.data.images.load("//textures/fingerprints_n.png")
nm = fmat.node_tree.nodes.new("ShaderNodeNormalMap")
nm.inputs["Strength"].default_value = 0.6
fmat.node_tree.links.new(nm.outputs["Color"], fp.inputs["Normal"])

# Вариант B: вмятины в геометрии через Displace
bpy.ops.object.modifier_add(type='DISPLACE')  # на выделенном объекте
disp = bpy.context.object.modifiers["Displace"]
disp.texture = tex
disp.texture_coords = 'LOCAL'
disp.direction = 'NORMAL'
disp.strength = -0.004     # отрицательный = вдавливаем, а не выдавливаем
disp.mid_level = 0.5

Этап 5. Свет: скользящий, иначе вмятины пропадут

import math
bpy.ops.object.light_add(type='AREA', location=(-0.9, -0.7, 1.1),
                        rotation=(math.radians(-40), 0, math.radians(-55)))
key = bpy.context.object
key.name = "KeySlide"
key.data.shape = 'RECTANGLE'
key.data.size, key.data.size_y = 0.35, 1.2    # узкая полоса = скользящий блик
key.data.energy = 120

# Заполняющий очень слабый: контраст = читаемость микрорельефа
bpy.ops.object.light_add(type='AREA', location=(1.2, -1.0, 0.5))
fill = bpy.context.object
fill.data.energy = 15
fill.data.size = 1.5

# Камера: макро-объектив и малая глубина резкости — «кукольность»
cam = bpy.data.objects["Camera"]
cam.data.lens = 85
cam.data.dof.use_dof = True
cam.data.dof.focus_distance = 1.4
cam.data.dof.aperture_fstop = 2.8

Этап 6. Дрожание камеры

Рукотворность дают не только позы, но и «дыхание» камеры между дублями.

import math, random
rng = random.Random(11)
for pose, frame in enumerate(range(1, 73, 2), start=1):
    cam.location = (rng.uniform(-0.004, 0.004), rng.uniform(-0.004, 0.004), rng.uniform(-0.002, 0.002))
    cam.rotation_euler.z = math.radians(rng.uniform(-0.4, 0.4))
    cam.keyframe_insert("location", frame=frame)
    cam.keyframe_insert("rotation_euler", index=2, frame=frame)
set_constant_interpolation([cam])

⚙️ КЛЮЧЕВЫЕ ПАРАМЕТРЫ

Параметр Значение Когда менять
scene.frame_step 2 3 при 8 fps, 1 для 24 fps
Sheen Weight 0.55 выше 0.8 — «бархад», а не войлок
Roughness 0.92 1.0 — крашеный мел
hair_length 0.035 для крупных кукол 0.08
rendered_child_count 60 выше 150 — рендер встаёт колом
aperture_fstop 2.8 5.6, если ворс не в фокусе

📤 РЕЗУЛЬТАТ

Параметр Значение
Объём 36 отрендеренных кадров = 3 с при 12 fps (72 кадра диапазона через один)
Файлы felt_0001.png, felt_0003.png, … — номера идут через один, 1920×1080, Cycles
Рендер 40–180 с/кадр с ворсом и SSS → 25–110 минут на 36 кадров
Проход можно рендерить в 4 раза ниже и апскейлить — стоп-моушен это прощает

Про «4 раза ниже» стоит сказать точнее, потому что это экономия, а не потеря качества. Рендерить вчетверо ниже по каждой оси (960×540 вместо 1920×1080) безопасно: в пластилиновом кадре нет ни мелких надписей, ни тонких бликов, которые развалились бы апскейлом. А вот рендер втрое ниже по числу сэмплов бьёт по ворсу сильнее — там главный источник шума не амплитуда, а подповерхностное рассеяние на тонких пальцах, и после 64 сэмплов кайма по краям остаётся даже с денойзером. Практическая схема: рабочий проход на 128 сэмплов с use_denoising и adaptive_threshold = 0.01, финальный — на 512 с тем же порогом, разница между ними видна на щеке и на кончиках пальцев, а не в общем поле кадра.

Проверяется одним скриптом. Он отвечает на три вопроса, которые ломают stop-motion незаметно: сколько кадров реально уйдёт в рендер, не осталось ли где-нибудь сглаженной интерполяции (плавная анимация вместо поз) и назначен ли ворсу материал.

import bpy, os, glob

scene = bpy.context.scene

# 1. Сколько кадров реально уйдёт в рендер: frame_step режет диапазон
shots = list(range(scene.frame_start, scene.frame_end + 1, scene.frame_step))
fps_out = scene.render.fps / scene.frame_step
print("кадров в диапазоне:", scene.frame_end - scene.frame_start + 1,
      "| после frame_step:", len(shots))
print("темп на выходе:", fps_out, "fps | длительность:",
      round(len(shots) / fps_out, 2), "с")

# 2. Главная проверка: между позами не должно быть НИЧЕГО, кроме CONSTANT.
#    В 4.4+ кривые лежат в слотах, а не в action.fcurves — обходим дерево.
def fcurves_of(ob):
    ad = ob.animation_data
    if ad is None or ad.action is None:
        return []
    act = ad.action
    if getattr(act, "layers", None):
        out = []
        for layer in act.layers:
            for strip in layer.strips:
                cb = strip.channelbag(ad.action_slot)
                if cb:
                    out.extend(cb.fcurves)
        return out
    return list(act.fcurves)

bad = []
for ob in [o for o in scene.objects if o.animation_data]:
    for fc in fcurves_of(ob):
        kinds = sorted({kp.interpolation for kp in fc.keyframe_points})
        if kinds != ['CONSTANT']:
            bad.append((ob.name, fc.data_path, kinds))
print("кривых с интерполяцией:", len(bad))
for row in bad[:8]:
    print("   ", row)

# 3. Ворс: без материала strands рендерятся чёрным карандашом по краю силуэта
hero = bpy.data.objects["Hero"]
ps = hero.particle_systems[-1]
print("частиц:", ps.settings.count, "| детей:", ps.settings.rendered_child_count,
      "| длина:", round(ps.settings.hair_length, 3))
print("материал ворса:", repr(ps.settings.material_slot), "| слот:", ps.settings.material)

# 4. Кадры на диске
files = sorted(glob.glob(os.path.join(bpy.path.abspath("//render"), "felt_*.png")))
print("отрендерено:", len(files), "| ожидалось:", len(shots))

Ненулевой счётчик в п. 2 — это ровно тот случай, когда stop-motion тихо превратился в обычную анимацию. Пустой material_slot в п. 3 означает, что strands останутся без шейдера и дадут грязную кайму, а не мягкий ореол.

⚠️ ТИПОВЫЕ ОШИБКИ

Симптом Причина Решение
Движение всё равно плавное frame_step не влияет на анимацию, только на рендер интерполяция должна быть CONSTANT
KeyError: "Sheen Weight" версия до 4.0 использовать имя Sheen
Ворс не рендерится не задан материал или слишком мал tip_radius назначить материал, поднять root_radius до 0.0005
Рендер считается вечно rendered_child_count завышен 30–60 достаточно, а child_percent поднять
Вмятины не видны свет frontal поставить key сбоку-сверху, узкой полосой
Материал выглядит как резина нет подповерхностного слоя Subsurface Weight 0.1–0.15
Модель «плывёт» между дублями не применён CONSTANT к камере прогнать set_constant_interpolation и по камере

🔗 СВЯЗИ

Связанные материалы

Подобраны по тегам и категории.