

An undulated circle refers to a circular shape whose edge is wavy or rippled instead of perfectly smooth. The undulations can vary in amplitude and frequency, giving the circle a dynamic, organic look similar to a vibrating or water-like surface. This pattern is common in visual design, mathematics, and generative art for creating rhythmic, fluid aesthetics.
In geometry or computer graphics (e.g., Blender, Nannou, or Grasshopper), an undulated circle can be described as a parametric curve where the radius oscillates with respect to the angle θ. Mathematically, the formula can be expressed as:
where:
= base radius
= amplitude (how far the wave undulates outward/inward)
= number of undulations around the circle
This produces a periodic wave along the circumference, useful for procedural modeling or motion graphics.
Artists and designers often use undulated circles as:
Decorative frames or borders (frilly circular motifs)
Organic forms in ceramics and sculpture, such as porcelain circles with varied surface height
Pattern generation in digital illustration or vector art (concentric undulating line designs)
In generative art frameworks (like Nannou or Processing), such a circle can be created by varying the polar coordinate radius procedurally. This yields effects resembling ripples, waves, or breathing patterns that feel both geometric and natural.
use nannou::prelude::*;
use nannou::noise::{NoiseFn, Perlin};
struct Model {
smooth_points: Vec<Vec2>,
}
fn main() {
nannou::app(model).update(update).run();
}
fn catmull_rom_spline(points: &[Vec2], segments: usize) -> Vec<Vec2> {
let mut smoothed = Vec::new();
let n = points.len();
for i in 0..n {
// 순환형 (닫힌 도형이므로)
let p0 = points[(i + n - 1) % n];
let p1 = points[i];
let p2 = points[(i + 1) % n];
let p3 = points[(i + 2) % n];
for j in 0..segments {
let t = j as f32 / segments as f32;
let t2 = t * t;
let t3 = t2 * t;
let x = 0.5
* ((2.0 * p1.x)
+ (-p0.x + p2.x) * t
+ (2.0 * p0.x - 5.0 * p1.x + 4.0 * p2.x - p3.x) * t2
+ (-p0.x + 3.0 * p1.x - 3.0 * p2.x + p3.x) * t3);
let y = 0.5
* ((2.0 * p1.y)
+ (-p0.y + p2.y) * t
+ (2.0 * p0.y - 5.0 * p1.y + 4.0 * p2.y - p3.y) * t2
+ (-p0.y + 3.0 * p1.y - 3.0 * p2.y + p3.y) * t3);
smoothed.push(pt2(x, y));
}
}
smoothed
}
fn model(app: &App) -> Model {
app.new_window()
.size(1080, 1080)
.view(view)
.build()
.unwrap();
let num_points = 120;
let perlin = Perlin::new();
let mut points = Vec::new();
for i in 0..num_points {
let theta = i as f32 * TAU / num_points as f32;
let r = 400.0 + perlin.get([i as f64 * 0.1, 0.0]) as f32 * 150.0;
points.push(pt2(theta.cos() * r, theta.sin() * r));
}
// 🔹 스무스하게 보간된 점 생성
let smooth_points = catmull_rom_spline(&points, 10);
Model { smooth_points }
}
fn update(_app: &App, _model: &mut Model, _update: Update) {}
fn view(app: &App, model: &Model, frame: Frame) {
let draw = app.draw();
draw.background().color(BLACK);
// 스무스 곡선 (닫힌 형태)
draw.polyline()
.weight(40.0)
// .weight(random_range(1.0, 2.0))
.color(WHITE)
.points_closed(model.smooth_points.clone());
draw.to_frame(app, &frame).unwrap();
}
// nannou 라이브러리의 기본 모듈을 임포트합니다. 이 모듈은 Vec2, pt2, TAU 등의 유용한 타입과 상수를 제공하며, Nannou 앱 개발의 필수적인 prelude입니다.
use nannou::prelude::*;
// nannou의 noise 모듈에서 NoiseFn 트레이트와 Perlin 노이즈 생성자를 임포트합니다. NoiseFn은 노이즈 함수의 공통 인터페이스를 정의하며, Perlin은 자연스러운 노이즈를 생성하는 구조체입니다.
use nannou::noise::{NoiseFn, Perlin};
// Model 구조체를 정의합니다. 이는 Nannou 앱의 상태를 저장하는 데 사용되며, 여기서는 부드럽게 보간된 점들의 벡터를 저장합니다.
struct Model {
// smooth_points: 부드럽게 보간된 점들의 컬렉션. Vec<Vec2> 타입으로, 각 Vec2는 2D 좌표(x, y)를 나타내는 glam 라이브러리의 벡터입니다.
smooth_points: Vec<Vec2>,
}
// main 함수: 프로그램의 진입점. Nannou 앱을 초기화하고 실행합니다.
fn main() {
// nannou::app 함수를 호출해 앱을 생성합니다. model 함수를 초기 모델 생성기로, update 함수를 업데이트 콜백으로, run() 메서드로 앱을 시작합니다.
// app는 Nannou의 핵심 앱 빌더로, 윈도우, 이벤트 루프 등을 관리합니다.
nannou::app(model).update(update).run();
}
// catmull_rom_spline 함수: 주어진 점들 사이를 Catmull-Rom 스플라인으로 보간해 부드러운 곡선 점들을 생성합니다. (별도 설명 섹션 참조)
fn catmull_rom_spline(points: &[Vec2], segments: usize) -> Vec<Vec2> {
// smoothed: 보간된 점들을 저장할 빈 벡터. Vec::new()로 초기화되며, 함수가 반환할 결과입니다.
let mut smoothed = Vec::new();
// n: 입력 점들의 개수. points 슬라이스의 길이를 저장하며, 루프 반복 횟수를 결정합니다.
let n = points.len();
// i 루프: 각 입력 점(i)에 대해 스플라인 세그먼트를 생성합니다. n번 반복해 닫힌 곡선을 만듭니다.
for i in 0..n {
// 순환형 (닫힌 도형이므로): 인덱스 모듈로 연산해 이전/이후 점을 순환적으로 가져옵니다.
// p0: i-1 번째 점 (순환: (i + n - 1) % n). Catmull-Rom 스플라인의 이전 제어점.
let p0 = points[(i + n - 1) % n];
// p1: 현재 점 i. 스플라인의 시작점.
let p1 = points[i];
// p2: 다음 점 (i+1) % n. 스플라인의 끝점.
let p2 = points[(i + 1) % n];
// p3: i+2 번째 점 (순환). 다음 세그먼트의 제어점.
let p3 = points[(i + 2) % n];
// j 루프: 각 세그먼트(p1 ~ p2) 내에서 segments 개의 중간 점을 샘플링합니다.
for j in 0..segments {
// t: 현재 세그먼트 내 파라미터 (0.0 ~ (segments-1)/segments). 스플라인 공식의 t 값으로, 0에서 1로 선형 보간.
let t = j as f32 / segments as f32;
// t2: t의 제곱. 스플라인 공식의 t² 항 계산을 위해.
let t2 = t * t;
// t3: t의 세제곱. 스플라인 공식의 t³ 항 계산을 위해.
let t3 = t2 * t;
// x: x 좌표 보간 값. Catmull-Rom 스플라인의 표준 공식 적용:
// 0.5 * (2*p1 + (-p0 + p2)*t + (2*p0 - 5*p1 + 4*p2 - p3)*t² + (-p0 + 3*p1 - 3*p2 + p3)*t³)
// 이 공식은 4개의 제어점(p0~p3)을 사용해 부드러운 cubic 곡선을 생성하며, t=0에서 p1, t=1에서 p2를 통과합니다.
let x = 0.5
* ((2.0 * p1.x)
+ (-p0.x + p2.x) * t
+ (2.0 * p0.x - 5.0 * p1.x + 4.0 * p2.x - p3.x) * t2
+ (-p0.x + 3.0 * p1.x - 3.0 * p2.x + p3.x) * t3);
// y: y 좌표 보간 값. x와 동일한 공식을 y에 적용.
let y = 0.5
* ((2.0 * p1.y)
+ (-p0.y + p2.y) * t
+ (2.0 * p0.y - 5.0 * p1.y + 4.0 * p2.y - p3.y) * t2
+ (-p0.y + 3.0 * p1.y - 3.0 * p2.y + p3.y) * t3);
// pt2(x, y): 새로운 Vec2 점 생성. pt2는 Nannou prelude의 헬퍼 함수로 Vec2(x, y)를 반환합니다.
// smoothed.push: 보간된 점을 벡터에 추가.
smoothed.push(pt2(x, y));
}
}
// smoothed: 모든 세그먼트의 점들이 추가된 벡터를 반환. 총 n * segments 개의 점으로 부드러운 곡선 근사.
smoothed
}
// model 함수: Nannou 앱의 초기 상태를 설정합니다. &App은 앱 인스턴스 참조, Model을 반환합니다.
fn model(app: &App) -> Model {
// app.new_window(): 새 윈도우를 생성하는 빌더 체인 시작. Nannou의 윈도우 설정을 위한 메서드입니다.
// .size(1080, 1080): 윈도우 크기를 1080x1080 픽셀로 설정. width와 height 인수.
// .view(view): view 함수를 렌더링 콜백으로 등록. view는 매 프레임마다 호출됩니다.
// .build(): 윈도우 빌더를 완료하고 생성. unwrap()으로 에러 처리 (실패 시 패닉).
app.new_window()
.size(1080, 1080)
.view(view)
.build()
.unwrap();
// num_points: 원형 점의 개수. 120개로 설정해 충분한 분포를 만듭니다.
let num_points = 120;
// perlin: Perlin 노이즈 생성자. Perlin::new()는 기본 시드의 Perlin 노이즈를 초기화합니다. get 메서드로 노이즈 값을 쿼리할 수 있습니다.
let perlin = Perlin::new();
// points: 기본 점들을 저장할 빈 벡터. 노이즈 적용 전 원형 점들.
let mut points = Vec::new();
// i 루프: num_points만큼 반복해 각 점 생성.
for i in 0..num_points {
// theta: 각 점의 각도. i를 num_points로 나눈 후 TAU(2π) 곱해 균등 분포. 원형 배치를 위한 극좌표 변환.
let theta = i as f32 * TAU / num_points as f32;
// r: 반경. 기본 400.0에 Perlin 노이즈를 적용: get([i*0.1, 0.0])으로 1D 노이즈 생성 (i를 스케일링해 주파수 조정), *150.0으로 진폭 설정. 결과적으로 250~550 범위의 불규칙 반경.
let r = 400.0 + perlin.get([i as f64 * 0.1, 0.0]) as f32 * 150.0;
// pt2(theta.cos() * r, theta.sin() * r): 직교좌표 변환. cos/sin으로 x/y 계산, pt2로 Vec2 생성.
// points.push: 생성된 점을 벡터에 추가.
points.push(pt2(theta.cos() * r, theta.sin() * r));
}
// 🔹 스무스하게 보간된 점 생성: catmull_rom_spline 호출. &points로 슬라이스 참조 전달, segments=10으로 각 세그먼트당 10개 점 샘플링 (총 120*10=1200개 점으로 부드러움 증대).
let smooth_points = catmull_rom_spline(&points, 10);
// Model { smooth_points }: 초기화된 Model 인스턴스 반환. smooth_points를 필드에 할당.
Model { smooth_points }
}
// update 함수: 앱의 업데이트 콜백. 매 프레임 업데이트 시 호출되지만, 여기서는 아무 작업도 하지 않습니다. _ 접두사로 사용되지 않음을 표시.
fn update(_app: &App, _model: &mut Model, _update: Update) {}
// view 함수: 렌더링 콜백. 매 프레임마다 호출되며, &App, &Model, Frame을 인수로 받습니다.
fn view(app: &App, model: &Model, frame: Frame) {
// draw: app.draw()로 Draw 컨텍스트 생성. 이는 그래픽 명령을 기록하는 빌더-like 객체로, 배경, 선, 도형 등을 그립니다.
let draw = app.draw();
// draw.background().color(BLACK): 배경을 검은색으로 채웁니다. background 메서드는 전체 캔버스를 클리어하고 색상을 적용합니다.
draw.background().color(BLACK);
// 스무스 곡선 (닫힌 형태): polyline으로 부드러운 점들을 연결해 선 그리기.
// draw.polyline(): 다각형 선을 그리는 메서드 체인 시작.
// .weight(40.0): 선의 두께를 40.0으로 설정. (주석된 .weight(random_range(1.0, 2.0))은 랜덤 두께 예시, 현재 비활성.)
// .color(WHITE): 선 색상을 흰색으로 설정.
// .points_closed(model.smooth_points.clone()): 닫힌 폴리라인으로 점들을 지정. points_closed는 점 벡터를 받아 첫/마지막 점을 연결하며, clone()으로 소유권 이전.
draw.polyline()
.weight(40.0)
// .weight(random_range(1.0, 2.0))
.color(WHITE)
.points_closed(model.smooth_points.clone());
// draw.to_frame(app, &frame).unwrap(): Draw 명령을 Frame 버퍼에 적용. 렌더링을 완료하고 화면에 출력합니다.
draw.to_frame(app, &frame).unwrap();
}
fn catmull_rom_spline(points: &[Vec2], segments: usize) -> Vec<Vec2>
각진 폴리곤(여기서는 노이즈가 적용된 원형 점들)을 부드러운 곡선으로 변환하기 위해 사용된 핵심 함수입니다. 이 함수는 Catmull-Rom 스플라인 알고리즘을 구현하며, 주어진 점 배열(points) 사이를 cubic(3차) 곡선으로 보간합니다.
목적:
직선으로 연결된 폴리라인(각진 도형)을 자연스럽고 부드러운 곡선으로 smoothing합니다. 이는 컴퓨터 그래픽스(예: 애니메이션 경로, 폰트 아웃라인)에서 흔히 사용되며, 제어점이 곡선을 "통과"하도록 설계되어 직관적입니다.
제어점:
각 세그먼트는 4개의 점(p0, p1, p2, p3)을 사용합니다.
단점: 과도한 곡률(overshoot)이 발생할 수 있으나, 여기서는 노이즈가 적용된 점으로 인해 자연스럽습니다.
순환형 처리: 닫힌 도형(원형)을 위해 모듈 연산(% n)을 사용해 끝과 시작을 연결합니다.
points: &[Vec2]: 입력 슬라이스. 참조로 전달되어 소유권 이전 없이 사용. 각 Vec2는 2D 점(x, y).
segments: usize: 각 세그먼트(p1 ~ p2)당 샘플링할 중간 점 수. 예: 10이면 세그먼트당 11개 점(끝포함) 생성. 값이 클수록 부드러움 증가(하지만 성능 저하).
반환: Vec<Vec2>: 보간된 모든 점의 벡터. 총 길이 = points.len() * segments. 이 점들을 polyline에 사용해 부드러운 선으로 렌더링.
알고리즘 세부 (공식 유도)
함수는 각 입력 점 i에 대해 p1-p2 세그먼트를 처리합니다. 공식은 Catmull-Rom의 표준 parametric equation입니다:
t: [0, 1] 범위의 파라미터. j 루프로 segments만큼 균등 분할 (t = j / segments).
x/y 분리: 벡터 연산 대신 x와 y를 독립적으로 계산 (성능 최적화).
계수 의미:
t^0 항: p1 중심.t^1 항: p0-p2 간 선형 보간.t^2 / t^3 항: 곡률 제어 (p0/p3가 영향을 줌).0.5 곱셈: 공식의 정규화 팩터.model 함수에서 생성).draw.polyline().points_closed()에 직접 전달. weight(10.0)으로 선 두께 설정.segments를 동적으로 조정하거나, tension 파라미터 추가(기본 Catmull-Rom은 tension=0)로 더 세밀한 제어 가능.