WF 17: STOP-MOTION FELT LOOK
RU: Stepped interpolation, fuzz texture, fingerprint normal EN: Stepped interpolation, fuzz texture, fingerprint normal Пластилиновый кукольный мультфильм: кадры «встают» вместо плавного движения, поверх материала торчит ворс, а вмятины от пальцев читаются в бликах. Три приёма дают 80 % узнаваемости
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 и по камере |
🔗 СВЯЗИ
- Прочие в категории «Воркфлоу»: WF 23: VIRTUAL PRODUCTION LED, WF 24: PHOTOGRAMMETRY CLEANUP, WF 01: TEXT-TO-SCENE, WF 03: TEXTURE PIPELINE, WF 02: IMAGE-TO-3D
- По темам: CASE 03: PRODUCT VISUALIZATION, P46: UDIM WORKFLOW, CASE 05: PROCEDURAL CITY, CASE 07: STYLYZED BARREL (HAND-PAINT)
- Теги: #animation, #pipeline, #render, #shading, #texturing, #workflow
Связанные материалы
Подобраны по тегам и категории.