본문으로 건너뛰기
3D 아틀리에
사와로 선인장 3D 모형

끌어서 회전, 휠·두 손가락으로 확대

이 모형에 대하여

사와로(Carnegiea gigantea)는 미국 애리조나주 남부와 멕시코 소노라주에 걸친 소노라 사막에 자생하는 기둥선인장이다. 매우 느리게 자라 다 자라면 높이 12m 안팎에 이르고, 150년 넘게 산다. 팔은 보통 수십 년이 지나서야 나기 시작한다.

줄기 표면은 아코디언처럼 접힌 세로 능선으로 이루어져 있어, 비가 오면 주름이 펴지며 물을 저장하고 건기에는 다시 오므라든다. 능선 마루에는 가시자리가 일정한 간격으로 늘어서 있고, 오래된 밑동은 코르크처럼 굳어 회갈색을 띤다. 늦봄에서 초여름 사이 줄기와 팔 끝에 흰 꽃이 피며, 이 꽃은 애리조나주의 주 꽃이다.

이 모델은 개화기의 성숙한 개체를 실제 크기(높이 약 9.6m)로 재현했다. 줄기에서 수평으로 나와 위로 꺾여 오르는 팔 네 개, 줄기 20개·팔 15개의 능선, 능선을 따라 늘어선 가시 다발, 꼭대기의 꽃과 봉오리·붉은 열매, 그리고 밑동의 코르크화된 껍질을 표현했다.

만든 방법

줄기와 팔은 3D 경로를 따라 단면을 쌓는 '주름진 관'을 bmesh 로 직접 생성했다. 경로마다 평행이동 프레임을 계산해 비틀림 없이 단면을 놓고, 단면 반지름을 |cos|^0.75 함수로 깎아 둥근 마루와 V자 골을 가진 능선을 만들었으며 끝은 반타원 돔으로 닫았다. 팔은 줄기 내부에서 출발해 수평 구간, 사분원 꺾임, 수직 구간으로 이어지고, 붙는 부분은 실제처럼 잘록하게 좁혔다. 가시자리와 가시 다발 수천 개는 능선 마루 좌표에서 계산해 한 메시로 묶었고, 꽃은 꽃잎 12장의 물결을 준 회전체 하나를 링크 복제해 꼭대기 둘레에 배치했다. 표면의 미세한 주름 질감, 새가 판 둥지 구멍, 꽃 내부 구조는 생략하거나 단순화했다.

build.py

이 파일 하나를 Blender 에서 실행하면 위의 모형이 만들어집니다. 440줄.

# 사와로 선인장 (Carnegiea gigantea) — 소노라 사막의 거대 기둥선인장.
# 줄기와 팔은 3D 경로를 따라 '주름진 관'을 bmesh 로 직접 짜서 만들었다:
# 경로 위 각 지점에서 평행이동 프레임을 구하고, 단면 반지름을 |cos|^p 함수로 깎아 세로 능선(rib)을 낸 뒤
# 끝은 반타원 돔으로 닫는다. 팔은 줄기 속에서 출발해 수평으로 나왔다가 사분원으로 꺾여 위로 자란다.
# 능선 마루를 따라 일정 간격으로 가시자리(펠트)와 가시 다발을 수학적으로 배치해 한 메시로 묶고,
# 줄기·팔 꼭대기에는 회전체로 만든 흰 꽃과 꽃봉오리·붉은 열매를 얹었다. 밑동은 코르크화된 회갈색 재질로 칠했다.
import random
from atelier import *
random.seed(1907)
SHOT = {"azimuth": -96, "elevation": 9, "lens": 50, "light": "day", "background": "#2e3644"}
# ---------------------------------------------------------------- 재질
M_SKIN = mat('SaguaroSkin', '#66795c', rough=0.62)
M_BARK = mat('CorkyBark', '#7c7366', rough=0.9)
M_SPINE = mat('Spine', '#b9ab90', rough=0.55)
M_FELT = mat('AreoleFelt', '#5c4c3c', rough=0.95)
M_PETAL = mat('FlowerPetal', '#efe8d6', rough=0.5)
M_STAMEN = mat('Stamen', '#e2b437', rough=0.55)
M_BUD = mat('FlowerTube', '#7f9150', rough=0.6)
M_FRUIT = mat('Fruit', '#a63c35', rough=0.45)
M_SOIL = mat('DesertSand', '#bb9c7a', rough=0.95)
M_ROCK = mat('Rock', '#87695a', rough=0.85)
# ---------------------------------------------------------------- 도우미
def frames(pts):
"""경로 점 → (점, 접선, 법선, 종법선) — 평행이동(비틀림 없는) 프레임."""
n = len(pts)
T = []
for i in range(n):
a = pts[max(i - 1, 0)]
b = pts[min(i + 1, n - 1)]
T.append((b - a).normalized())
up = Vector((0, 0, 1)) if abs(T[0].z) < 0.9 else Vector((1, 0, 0))
N0 = (up - T[0] * up.dot(T[0])).normalized()
out = []
N = N0
for i in range(n):
if i > 0:
q = T[i - 1].rotation_difference(T[i])
N = q @ N
N = (N - T[i] * N.dot(T[i])).normalized()
B = T[i].cross(N)
out.append((pts[i], T[i], N, B))
return out
나머지 392줄 펼치기
def rib_profile(theta, nribs, depth):
"""능선 마루=1, 골=1-depth. 마루는 둥글고 골은 V 자."""
ph = theta * nribs / (2 * math.pi)
f = abs(math.cos(math.pi * ph)) ** 0.75
return 1.0 - depth * (1.0 - f)
def ribbed_tube(name, path, radius_fn, nribs, depth=0.16, vpr=8, dome=0.85, phase=0.0,
spine_step=0.07, spine_len=(0.025, 0.05)):
"""path: Vector 목록(대략 균일 간격). radius_fn(s) — s=경로 길이(m).
반환: (오브젝트, 가시자리 목록[(위치, 바깥방향, 가시길이배율)])"""
fr = frames(path)
seglen = [0.0]
for i in range(1, len(path)):
seglen.append(seglen[-1] + (path[i] - path[i - 1]).length)
L = seglen[-1]
rings = [] # (center, N, B, r, s, isdome)
for (p, t, n, b), s in zip(fr, seglen):
rings.append((p, n, b, radius_fn(s), s, 0.0))
# 끝 돔: 마지막 접선 방향으로 반타원
p_end, t_end, n_end, b_end = fr[-1]
R_end = rings[-1][3]
steps = 12
for k in range(1, steps + 1):
phi = (math.pi / 2) * k / steps
r = R_end * math.cos(phi)
p = p_end + t_end * (R_end * dome * math.sin(phi))
rings.append((p, n_end, b_end, max(r, 0.0), L + R_end * dome * math.sin(phi), k / steps))
bm = bmesh.new()
m = nribs * vpr
grid = []
for (c, n, b, r, s, dm) in rings:
ring = []
for j in range(m):
th = 2 * math.pi * j / m + phase
rr = r * rib_profile(th, nribs, depth)
ring.append(bm.verts.new(c + (n * math.cos(th) + b * math.sin(th)) * rr))
grid.append(ring)
for i in range(len(grid) - 1):
for j in range(m):
jn = (j + 1) % m
if i == len(grid) - 2:
pass
bm.faces.new((grid[i][j], grid[i][jn], grid[i + 1][jn], grid[i + 1][j]))
bm.faces.new(list(reversed(grid[0])))
bmesh.ops.remove_doubles(bm, verts=grid[-1], dist=1e-6)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
ob = bpy.data.objects.new(name, me)
link(ob)
smooth(ob)
# 가시자리: 능선 마루를 따라
areoles = []
acc = -1.0
for idx, (c, n, b, r, s, dm) in enumerate(rings):
if r < 0.02:
continue
if s - acc < spine_step * (0.55 if dm > 0 else 1.0):
continue
acc = s
for k in range(nribs):
th = 2 * math.pi * k / nribs + phase
d = (n * math.cos(th) + b * math.sin(th))
pos = c + d * r
if dm > 0: # 돔 위에서는 바깥방향을 접선 쪽으로 기울인다
t_dir = (rings[min(idx + 1, len(rings) - 1)][0] - c)
if t_dir.length > 1e-6:
d = (d * math.cos(dm * math.pi / 2) + t_dir.normalized() * math.sin(dm * math.pi / 2)).normalized()
areoles.append((pos, d, 1.0 + 0.8 * dm))
return ob, areoles
def build_spines(name, areoles, base_len=0.035):
"""가시자리 펠트(작은 사각뿔) + 가시 5~7개(가는 삼각뿔)를 한 메시로."""
bm = bmesh.new()
felt_faces = []
for pos, d, lmul in areoles:
d = d.normalized()
a = d.orthogonal().normalized()
b2 = d.cross(a)
# 펠트 혹
fw = 0.009
base = [bm.verts.new(pos - d * 0.004 + (a * math.cos(q) + b2 * math.sin(q)) * fw)
for q in (0, math.pi / 2, math.pi, 1.5 * math.pi)]
tip = bm.verts.new(pos + d * 0.006)
for i in range(4):
felt_faces.append(bm.faces.new((base[i], base[(i + 1) % 4], tip)))
# 가시
ns = random.randint(5, 7)
for k in range(ns):
q = 2 * math.pi * k / ns + random.uniform(-0.3, 0.3)
spread = random.uniform(0.45, 0.95)
dirv = (d * math.cos(spread) + (a * math.cos(q) + b2 * math.sin(q)) * math.sin(spread)).normalized()
if k == 0:
dirv = (d + a * 0.15).normalized() # 가운데 큰 가시
ln = base_len * lmul * random.uniform(0.6, 1.2) * (1.5 if k == 0 else 1.0)
o = dirv.orthogonal().normalized()
o2 = dirv.cross(o)
w = 0.0018
root = pos + d * 0.002
bv = [bm.verts.new(root + (o * math.cos(t) + o2 * math.sin(t)) * w) for t in (0, 2.094, 4.189)]
tv = bm.verts.new(root + dirv * ln)
for i in range(3):
bm.faces.new((bv[i], bv[(i + 1) % 3], tv))
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
for f in felt_faces:
f.material_index = 1
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
ob = bpy.data.objects.new(name, me)
link(ob)
me.materials.append(M_SPINE)
me.materials.append(M_FELT)
return ob
def paint_bark(ob, z_lim):
"""밑동의 코르크화: z_lim(각도) 아래 면에 껍질 재질."""
me = ob.data
me.materials.clear()
me.materials.append(M_SKIN)
me.materials.append(M_BARK)
for p in me.polygons:
c = p.center
ang = math.atan2(c.y, c.x)
if c.z < z_lim(ang):
p.material_index = 1
# ---------------------------------------------------------------- 줄기
TRUNK_H = 9.2
def trunk_r(s):
# 밑동에서 살짝 좁고, 1~2m 에서 가장 굵고, 위로 갈수록 아주 조금 가늘어진다
base = 0.27 + 0.07 * min(s / 1.4, 1.0) ** 0.7
taper = -0.03 * max(s - 2.0, 0) / (TRUNK_H - 2.0)
growth = 0.006 * math.sin(s * 2.3 + 0.7) + 0.004 * math.sin(s * 5.1) # 가뭄·우기 해에 따른 미세한 굵기 변화
return base + taper + growth
trunk_path = [Vector((0, 0, z)) for z in [i * 0.05 for i in range(int(TRUNK_H / 0.05) + 1)]]
trunk, trunk_areoles = ribbed_tube('Saguaro_Trunk', trunk_path, trunk_r, nribs=20, depth=0.17, vpr=8, dome=0.8)
paint_bark(trunk, lambda a: 0.75 + 0.22 * math.sin(3 * a) + 0.08 * math.sin(7 * a + 1.0))
# ---------------------------------------------------------------- 팔
def arm_path(h0, az, out_len, bend_r, up_len, lean=0.0, droop=0.0):
"""줄기 속(축에서 0.1m)에서 출발 → 수평으로 out_len → 사분원(bend_r)으로 꺾여 → 위로 up_len."""
d = Vector((math.cos(az), math.sin(az), 0))
z = Vector((0, 0, 1))
pts = []
step = 0.045
start = d * 0.1 + z * h0
# 수평 구간 (약간 아래로 처졌다가)
n1 = max(int(out_len / step), 2)
for i in range(n1):
t = i / n1
pts.append(start + d * (out_len * t) - z * (droop * math.sin(math.pi * t)))
c0 = start + d * out_len
# 사분원 꺾임
n2 = max(int(bend_r * math.pi / 2 / step), 4)
for i in range(n2):
a = (math.pi / 2) * i / n2
pts.append(c0 + d * (bend_r * math.sin(a)) + z * (bend_r * (1 - math.cos(a))))
c1 = c0 + d * bend_r + z * bend_r
# 위로 (lean 만큼 바깥으로 기울어짐)
n3 = max(int(up_len / step), 2)
for i in range(n3 + 1):
t = i / n3
pts.append(c1 + z * (up_len * t) + d * (lean * t * t))
return pts
def make_arm_r(L, r_max, r_join):
def f(s):
# 줄기와 붙는 곳은 잘록, 0.6m 뒤로 최대 굵기, 끝으로 조금 가늘게
k = min(max((s - 0.25) / 0.55, 0.0), 1.0)
k = k * k * (3 - 2 * k)
r = r_join + (r_max - r_join) * k
return r * (1.0 - 0.08 * s / L)
return f
ARMS = [
# 높이, 방위(도), 수평길이, 꺾임반경, 위길이, 바깥기울기, 최대반경
(2.9, 8, 0.45, 0.45, 2.7, 0.12, 0.25),
(3.8, 186, 0.40, 0.42, 3.2, 0.07, 0.245),
(4.3, 112, 0.32, 0.40, 1.9, 0.08, 0.225),
(5.6, -62, 0.30, 0.36, 1.3, 0.05, 0.21),
]
arms = []
arm_areoles = []
for i, (h0, azd, ol, br, ul, lean, rmax) in enumerate(ARMS):
pts = arm_path(h0, math.radians(azd), ol, br, ul, lean=lean, droop=0.04)
L = sum((pts[k] - pts[k - 1]).length for k in range(1, len(pts)))
ob, ar = ribbed_tube(f'Saguaro_Arm_{i + 1}', pts, make_arm_r(L, rmax, rmax * 0.62), nribs=15,
depth=0.17, vpr=8, dome=0.8, phase=0.13 * i)
assign(ob, M_SKIN)
# 줄기 속에 묻힌 가시자리는 버린다
tr = trunk_r(h0)
ar = [a for a in ar if Vector((a[0].x, a[0].y)).length > tr * 0.98 + 0.02]
arms.append(ob)
arm_areoles += ar
spines = build_spines('Saguaro_Spines_and_Areoles', trunk_areoles + arm_areoles)
# ---------------------------------------------------------------- 꽃·봉오리·열매 (5~6월 개화기)
def flower_mesh():
"""회전체 꽃: 녹색 꽃통 + 흰 꽃잎 컵(꽃잎 12장 물결) + 노란 수술 방석."""
bm = bmesh.new()
seg = 36
petals = 12
prof_tube = [(0.0, 0.0), (0.012, 0.0), (0.016, 0.03), (0.022, 0.055), (0.0, 0.055)]
prof_cup = [(0.018, 0.05), (0.03, 0.065), (0.042, 0.08), (0.05, 0.095), (0.046, 0.099),
(0.036, 0.086), (0.024, 0.072), (0.014, 0.064)]
prof_st = [(0.0, 0.066), (0.016, 0.066), (0.017, 0.076), (0.008, 0.08), (0.0, 0.08)]
parts = []
for prof, scal in ((prof_tube, False), (prof_cup, True), (prof_st, False)):
rings = []
for i in range(seg):
a = 2 * math.pi * i / seg
ring = []
for r, z in prof:
rr = r
zz = z
if scal and r > 0.025:
w = abs(math.cos(a * petals / 2)) ** 0.6
rr = r * (0.9 + 0.1 * w)
zz = z + 0.006 * (w - 0.5)
ring.append(bm.verts.new((rr * math.cos(a), rr * math.sin(a), zz)))
rings.append(ring)
faces = []
for i in range(seg):
r0, r1 = rings[i], rings[(i + 1) % seg]
for j in range(len(prof) - 1):
faces.append(bm.faces.new((r0[j], r1[j], r1[j + 1], r0[j + 1])))
if scal: # 컵은 고리: 마지막-처음 이어 닫기
for i in range(seg):
r0, r1 = rings[i], rings[(i + 1) % seg]
faces.append(bm.faces.new((r0[-1], r1[-1], r1[0], r0[0])))
parts.append(faces)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-6)
for fi, mi in ((1, 1), (2, 2)):
for f in parts[fi]:
if f.is_valid:
f.material_index = mi
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
me = bpy.data.meshes.new('SaguaroFlower')
bm.to_mesh(me)
bm.free()
for p in me.polygons:
p.use_smooth = True
me.materials.append(M_BUD)
me.materials.append(M_PETAL)
me.materials.append(M_STAMEN)
return me
FLOWER_ME = flower_mesh()
BUD = sphere('Saguaro_Bud_proto', r=0.022, material=M_BUD, seg=12, rings=8, scale=(1, 1, 1.6))
FRUIT = sphere('Saguaro_Fruit_proto', r=0.026, material=M_FRUIT, seg=12, rings=8, scale=(1, 1, 1.45))
def crown(tag, tip, axis, R, n_fl, n_bud, n_fruit):
"""돔 꼭대기 둘레에 꽃·봉오리·열매를 바깥을 향해 꽂는다."""
axis = axis.normalized()
a = axis.orthogonal().normalized()
b = axis.cross(a)
items = [('F', n_fl), ('B', n_bud), ('R', n_fruit)]
kinds = []
for k, n in items:
kinds += [k] * n
random.shuffle(kinds)
total = len(kinds)
for i, k in enumerate(kinds):
q = 2 * math.pi * i / total + random.uniform(-0.15, 0.15)
pol = random.uniform(0.25, 0.75) # 돔 위 극각(rad)
radial = a * math.cos(q) + b * math.sin(q)
nrm = (axis * math.cos(pol) + radial * math.sin(pol)).normalized()
pos = tip - axis * (R * 0.8) + axis * (R * 0.8 * math.cos(pol)) + radial * (R * math.sin(pol))
pos -= nrm * 0.01 # 표면에 살짝 묻는다
rot = Vector((0, 0, 1)).rotation_difference(nrm).to_euler()
if k == 'F':
ob = bpy.data.objects.new(f'Flower_{tag}_{i}', FLOWER_ME)
link(ob)
elif k == 'B':
ob = duplicate(BUD, name=f'Bud_{tag}_{i}')
pos += nrm * 0.025
else:
ob = duplicate(FRUIT, name=f'Fruit_{tag}_{i}')
pos += nrm * 0.028
ob.location = pos
ob.rotation_euler = rot
s = random.uniform(0.9, 1.15)
ob.scale = (s, s, s)
# 줄기 꼭대기
tR = trunk_r(TRUNK_H)
crown('Trunk', Vector((0, 0, TRUNK_H + tR * 0.8)), Vector((0, 0, 1)), tR, 9, 6, 2)
for i, (h0, azd, ol, br, ul, lean, rmax) in enumerate(ARMS):
d = Vector((math.cos(math.radians(azd)), math.sin(math.radians(azd)), 0))
top = d * (0.1 + ol + br + lean) + Vector((0, 0, h0 + br + ul))
L = ol + br * math.pi / 2 + ul
rt = make_arm_r(L, rmax, rmax * 0.62)(L)
axis = (Vector((0, 0, ul)) + d * (2 * lean)).normalized()
crown(f'Arm{i + 1}', top + axis * (rt * 0.8), axis, rt, 5, 3, 1 if i % 2 == 0 else 0)
# 원형 프로토타입은 숨긴다(메시만 공유)
BUD.hide_render = True
BUD.hide_viewport = True
FRUIT.hide_render = True
FRUIT.hide_viewport = True
BUD['atelier_cutter'] = True
FRUIT['atelier_cutter'] = True
# ---------------------------------------------------------------- 사막 바닥 둔덕과 바위
def sand_mound(R=2.4, h=0.14):
bm = bmesh.new()
nr, na = 14, 72
center = bm.verts.new((0, 0, h))
rings = []
for i in range(1, nr + 1):
t = i / nr
ring = []
for j in range(na):
a = 2 * math.pi * j / na
edge = 1 + 0.06 * math.sin(3 * a + 0.4) + 0.035 * math.sin(5 * a + 2.0) + 0.015 * math.sin(11 * a)
r = R * t * edge
z = h * (1 - t ** 1.6) ** 1.5 + 0.012 * math.sin(9 * a + 7 * t) * (1 - t) if i < nr else 0.0
ring.append(bm.verts.new((r * math.cos(a), r * math.sin(a), max(z, 0.0))))
rings.append(ring)
for j in range(na):
bm.faces.new((center, rings[0][j], rings[0][(j + 1) % na]))
for i in range(nr - 1):
for j in range(na):
jn = (j + 1) % na
bm.faces.new((rings[i][j], rings[i + 1][j], rings[i + 1][jn], rings[i][jn]))
bm.faces.new(list(reversed(rings[-1])))
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
me = bpy.data.meshes.new('Desert_Sand_Mound')
bm.to_mesh(me)
bm.free()
ob = bpy.data.objects.new('Desert_Sand_Mound', me)
link(ob)
smooth(ob)
return assign(ob, M_SOIL)
sand_mound()
def rock(name, loc, size, rotz):
bm = bmesh.new()
bmesh.ops.create_icosphere(bm, subdivisions=3, radius=1.0)
for v in bm.verts:
n = v.co.normalized()
k = 1 + 0.18 * math.sin(3.1 * n.x + 1.7 * n.y * 2) + 0.12 * math.sin(4.3 * n.z + n.x * 3) + random.uniform(-0.05, 0.05)
v.co = n * k
bmesh.ops.scale(bm, vec=Vector(size), verts=bm.verts)
for v in bm.verts: # 바닥 아래로 내려가지 않게 납작하게 자른다(반쯤 묻힌 모양)
v.co.z = max(v.co.z, -size[2] * 0.25)
bmesh.ops.translate(bm, vec=(0, 0, size[2] * 0.25), verts=bm.verts)
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
ob = bpy.data.objects.new(name, me)
link(ob)
ob.location = Vector(loc)
ob.rotation_euler.z = rotz
smooth(ob)
return assign(ob, M_ROCK)
rock('Rock_1', (1.25, -0.9, 0.0), (0.38, 0.28, 0.2), 0.4)
rock('Rock_2', (1.55, -0.45, 0.0), (0.2, 0.16, 0.12), 1.3)
rock('Rock_3', (-1.1, 1.3, 0.0), (0.3, 0.24, 0.16), 2.2)
rock('Rock_4', (-1.6, -0.7, 0.0), (0.16, 0.13, 0.09), 0.8)
for i in range(10): # 자갈
a = random.uniform(0, 2 * math.pi)
r = random.uniform(0.7, 1.9)
sz = random.uniform(0.04, 0.08)
rock(f'Pebble_{i + 1}', (r * math.cos(a), r * math.sin(a), 0.0), (sz, sz * 0.8, sz * 0.55), a)

다른 모형