Houdini 21 — Procedural point cloud morphing with guide curves, falloff stagger, and coin stacking
You have 2 or 3 objects in roughly the same position, each made of coins:
Why NOT APEX? APEX is designed for KineFX character rigging (transform hierarchies), not point-cloud-level operations. You'd need one TransformObject per coin — impractical for thousands. The SOPs + VEX approach is the correct tool.
| Factor | SOPs + VEX | APEX |
|---|---|---|
| Point cloud operations | ✅ Native (Scatter, Wrangle) | ❌ No scatter/point ops |
| Thousands of instances | ✅ Copy to Points, packed prims | ❌ One TransformObject per coin |
| Custom morphing logic | ✅ VEX wrangles, full control | ⚠️ Only via RunVex |
| Physics integration | ✅ Vellum, POPs | ❌ No particle/DOP support |
| Performance | ✅ GPU OpenCL VEX in H21 | ⚠️ Unproven for this use case |
coin_geo (Subnetwork or HDA)┌──────────────────────────────────────────┐ │ Circle SOP (Radius: coin_size, │ │ Type: Polygon, Sides: 32+) │ │ ↓ │ │ PolyExtrude SOP (Depth: coin_thickness) │ │ ↓ │ │ PolyBevel SOP (Offset: edge_bevel) │ │ ↓ │ │ (Optional) Normal SOP (Point mode) │ │ ↓ │ │ (Optional) UV Layout SOP │ │ ↓ │ │ Null SOP → OUTPUT │ └──────────────────────────────────────────┘
Circle SOP:
ch("coin_size") — promote as parameter (default 0.05)PolyExtrude SOP:
ch("coin_thickness") — promote (default 0.01)PolyBevel SOP:
ch("edge_bevel") — promote (default 0.002)💡 Tip: Keep the coin low-poly (32 sides is fine). You're instancing thousands — polygon count multiplies fast.
Attribute Wrangle on each mesh (Run Over Detail):
// Calculate how many coins fit on this surface
float surface_area = getbbox_size(0).x * getbbox_size(0).z; // Approximate
float coin_footprint = ch("coin_size") * ch("coin_size") * 4.0; // πr² approx
int coin_count = ceil(surface_area / coin_footprint * ch("packing_density"));
setdetailattrib(0, "coin_count", coin_count, "set");
Scatter SOP Settings (all three):
primnumprimuvw⚠️ Critical: All scatter distributions must have the EXACT same point count. This is non-negotiable for the morph to work properly.
set_orient_A (Run Over Points)// Align coins to surface normal
v@N = normalize(v@N);
vector4 q = dihedral({0,0,1}, v@N);
p@orient = q;
p@orient_A = q; // Store for 3-stage morphing
v@pos_A = @P; // Store Object A position
f@pscale = 1.0; // Initialize scale
set_orient_Bv@N = normalize(v@N);
vector4 q = dihedral({0,0,1}, v@N);
p@orient = q;
p@orient_B = q;
v@pos_B = @P;
f@pscale = 1.0;
set_orient_Cv@N = normalize(v@N);
vector4 q = dihedral({0,0,1}, v@N);
p@orient = q;
p@orient_C = q;
v@pos_C = @P;
f@pscale = 1.0;
💡 What dihedral does: Creates a quaternion that rotates from the default up vector {0,0,1} to the surface normal v@N. This makes coins align to the surface they're scattered on.
int target_pt = nearpoint(1, @P);
v@target_pos = point(1, "P", target_pt);
p@target_orient = point(1, "orient", target_pt);
// After Attribute Sort SOP (H21!) sorts both clouds the same way:
v@target_pos = point(1, "P", @ptnum);
p@target_orient = point(1, "orient", @ptnum);
int seed = chi("match_seed");
float jitter = ch("match_jitter");
int offset = int(rand(@ptnum + seed) * jitter * npoints(1));
int target_pt = (@ptnum + offset) % npoints(1);
v@target_pos = point(1, "P", target_pt);
p@target_orient = point(1, "orient", target_pt);
int pt_B = nearpoint(1, v@pos_A);
int pt_C = nearpoint(2, v@pos_A);
v@pos_B = point(1, "P", pt_B);
p@orient_B = point(1, "orient", pt_B);
v@pos_C = point(2, "P", pt_C);
p@orient_C = point(2, "orient", pt_C);
// Get falloff object position (input 1 = falloff null)
vector falloff_pos = point(1, "P", 0);
float dist = length(@P - falloff_pos);
// 0 = transforms first, 1 = transforms last
float radius = ch("falloff_radius");
f@falloff = fit(dist, 0, radius, 0, 1);
f@falloff = chramp("falloff_curve", f@falloff);
float n = anoise(@P * ch("noise_freq"),
chi("noise_octaves"),
ch("noise_rough"),
ch("noise_atten"));
f@falloff = fit(n, -1, 1, 0, 1);
f@falloff = chramp("falloff_curve", f@falloff);
float object_falloff = fit(length(@P - falloff_pos), 0, ch("radius"), 0, 1);
float noise_falloff = fit(anoise(@P * ch("noise_freq")), -1, 1, 0, 1);
f@falloff = lerp(object_falloff, noise_falloff, ch("noise_mix"));
// Sample SDF at point position
float sdf = volumesample(1, 0, @P);
f@falloff = fit(sdf, -ch("inner_radius"), ch("outer_radius"), 0, 1);
f@falloff = clamp(f@falloff, 0, 1);
Curve SOP (Draw Mode: Bezier or NURBS) → Resample SOP (Even spacing, ~50-100 points per curve) → Null SOP → "guide_curves"
Since objects are in nearly the same position, the curves should arc outward and return:
╭─── guide curve ───╮
/ \
/ \
Object A ←──── same area ────→ Object B
// Random assignment
int num_curves = npoints(1) > 0 ? nprimitives(1) : 1;
i@curve_id = int(fit(rand(@ptnum + ch("curve_seed")), 0, 1, 0, num_curves));
// OR: spatial proximity (better)
int closest_prim;
vector closest_uv;
xyzdist(1, @P, closest_prim, closest_uv);
i@curve_id = closest_prim;
f@curve_u = fit(rand(@ptnum + 42), 0, 1, 0.1, 0.9);
int curve = i@curve_id;
float u = f@curve_u;
vector curve_pos = primuv(1, "P", curve, set(u, 0, 0));
vector curve_tangent = primuv(1, "P", curve, set(u + 0.01, 0, 0)) - curve_pos;
v@guide_pos = curve_pos;
v@guide_tangent = normalize(curve_tangent);
// For A→B transition (objects in same position)
f@guide1_u_entry = 0.2 + rand(@ptnum) * 0.1;
f@guide1_u_exit = 0.8 + rand(@ptnum + 99) * 0.1;
// For B→C transition
f@guide2_u_entry = 0.2 + rand(@ptnum + 200) * 0.1;
f@guide2_u_exit = 0.8 + rand(@ptnum + 299) * 0.1;
// ==========================================
// COIN MORPH — 2-Stage Animation Wrangle
// ==========================================
float t = @Time;
float start = chf("start_time");
float duration = chf("duration");
// --- PER-POINT STAGGER ---
float stagger_noise = noise(@ptnum * ch("stagger_freq") + ch("stagger_seed"));
float stagger_delay = stagger_noise * ch("stagger_amount");
vector center = chv("propagation_center");
float dist_to_center = length(v@source_pos - center);
float spatial_delay = fit(dist_to_center, 0, ch("max_radius"), 0, ch("spatial_delay"));
float total_delay = max(stagger_delay, spatial_delay);
// --- LOCAL BLEND ---
float local_start = start + total_delay;
float elapsed = t - local_start;
float blend = clamp(elapsed / duration, 0, 1);
blend = chramp("blend_curve", blend);
// --- POSITION MORPH WITH ARC ---
vector src_P = v@source_pos;
vector tgt_P = v@target_pos;
vector mid_P = lerp(src_P, tgt_P, 0.5);
float arc_height = ch("arc_height");
float arc_var = noise(@ptnum * 5.678) * ch("arc_variation");
mid_P += {0, 1, 0} * (arc_height + arc_var);
// Curl noise turbulence
vector turbulence = curlnoise(@P * ch("curl_freq") + t * ch("curl_speed"));
turbulence *= sin(blend * 3.14159) * ch("curl_amp");
// Quadratic bezier: src → mid → tgt
vector a = lerp(src_P, mid_P, blend);
vector b = lerp(mid_P, tgt_P, blend);
@P = lerp(a, b, blend) + turbulence;
// --- ORIENTATION MORPH ---
vector4 src_q = p@source_orient;
vector4 tgt_q = p@target_orient;
p@orient = slerp(src_q, tgt_q, blend);
// Tumble during flight
if (blend > 0.0 && blend < 1.0) {
float tumble_amount = sin(blend * 3.14159) * ch("tumble_amount");
vector tumble_axis = normalize(cross(tgt_P - src_P, {0,1,0}) + 0.001);
vector4 tumble_q = quaternion(radians(tumble_amount * 360), tumble_axis);
p@orient = qmultiply(p@orient, tumble_q);
}
// --- SCALE ---
float flight_pulse = sin(blend * 3.14159) * ch("scale_bulge");
f@pscale = (1.0 + flight_pulse);
// --- VELOCITY ---
v@v = @P - v@source_pos;
// ============================================================
// 3-STAGE COIN MORPH — Master Animation Wrangle
// Input 0 = points, Input 1 = guide curves A→B, Input 2 = guide curves B→C
// ============================================================
float t = @Time;
// ── TIMING ──
float t1_start = chf("t1_start");
float t1_dur = chf("t1_duration");
float t2_start = chf("t2_start");
float t2_dur = chf("t2_duration");
// ── FALLOFF STAGGER ──
float stagger_max = ch("stagger_max");
float delay = f@falloff * stagger_max;
// ── TRANSITION 1: A → B ──
float t1_local = clamp((t - t1_start - delay) / t1_dur, 0, 1);
t1_local = chramp("t1_curve", t1_local);
// ── TRANSITION 2: B → C ──
float t2_local = clamp((t - t2_start - delay) / t2_dur, 0, 1);
t2_local = chramp("t2_curve", t2_local);
// ── GUIDE CURVE SAMPLING ──
float guide1_u = lerp(f@guide1_u_entry, f@guide1_u_exit, t1_local);
vector guide1_pos = primuv(1, "P", i@guide1_curve, set(guide1_u, 0, 0));
float guide2_u = lerp(f@guide2_u_entry, f@guide2_u_exit, t2_local);
vector guide2_pos = primuv(2, "P", i@guide2_curve, set(guide2_u, 0, 0));
// ── 3-PHASE BLEND ──
vector pos_A = v@pos_A;
vector pos_B = v@pos_B;
vector pos_C = v@pos_C;
// --- TRANSITION 1: A → B via guide1 ---
vector flight1_pos;
if (t1_local <= 0) {
flight1_pos = pos_A;
} else if (t1_local >= 1) {
flight1_pos = pos_B;
} else {
float guide_weight = sin(t1_local * 3.14159);
vector direct_path = lerp(pos_A, pos_B, t1_local);
flight1_pos = lerp(direct_path, guide1_pos, guide_weight * ch("guide_influence_1"));
vector turb = curlnoise(@P * ch("turb_freq") + t * 0.5) * sin(t1_local * 3.14159) * ch("turb_amp");
flight1_pos += turb;
}
// --- TRANSITION 2: B → C via guide2 ---
vector flight2_pos;
if (t2_local <= 0) {
flight2_pos = pos_B;
} else if (t2_local >= 1) {
flight2_pos = pos_C;
} else {
float guide_weight = sin(t2_local * 3.14159);
vector direct_path = lerp(pos_B, pos_C, t2_local);
flight2_pos = lerp(direct_path, guide2_pos, guide_weight * ch("guide_influence_2"));
vector turb = curlnoise(@P * ch("turb_freq") + t * 0.5 + 100) * sin(t2_local * 3.14159) * ch("turb_amp");
flight2_pos += turb;
}
// --- COMBINE ---
if (t2_local > 0) {
@P = flight2_pos;
} else {
@P = flight1_pos;
}
// ── ORIENTATION ──
vector4 orient_t1 = slerp(p@orient_A, p@orient_B, t1_local);
vector4 orient_t2 = slerp(p@orient_B, p@orient_C, t2_local);
if (t1_local > 0 && t1_local < 1) {
float tumble = sin(t1_local * 3.14159) * ch("tumble_amount");
vector t_axis = normalize(v@pos_B - v@pos_A + 0.001);
orient_t1 = qmultiply(orient_t1, quaternion(radians(tumble * 360), t_axis));
}
if (t2_local > 0 && t2_local < 1) {
float tumble = sin(t2_local * 3.14159) * ch("tumble_amount");
vector t_axis = normalize(v@pos_C - v@pos_B + 0.001);
orient_t2 = qmultiply(orient_t2, quaternion(radians(tumble * 360), t_axis));
}
p@orient = t2_local > 0 ? orient_t2 : orient_t1;
// ── SCALE PULSE ──
float scale = 1.0;
if ((t1_local > 0 && t1_local < 1) || (t2_local > 0 && t2_local < 1)) {
float active = t2_local > 0 ? t2_local : t1_local;
scale += sin(active * 3.14159) * ch("scale_bulge");
}
f@pscale = scale;
// ── VELOCITY ──
v@v = @P - v@prev_P;
v@prev_P = @P;
When a smaller target object can't fit all coins on its surface, excess coins stack on top.
// Wrangle: stack_coins (Run Over Points on target scatter)
int total_coins = npoints(0);
int base_capacity = ch("base_capacity");
if (@ptnum < base_capacity) {
i@layer = 0;
f@stack_offset = 0;
} else {
i@layer = 1 + (@ptnum - base_capacity) / base_capacity;
f@stack_offset = i@layer * ch("coin_thickness") * 2.0;
v@N = normalize(v@N);
@P += v@N * f@stack_offset;
vector jitter = set(rand(@ptnum+1)-0.5, 0, rand(@ptnum+2)-0.5) * ch("coin_size") * 0.5;
@P += jitter;
}
Time ──────────────────────────────────────────────────→
Object A: ████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ (at rest → departing)
╰─ falloff starts triggering coins
Flight 1: ╭────────╮ (guide curve flight)
╰────────────────╯
Object B: ░░░░████████████████░░░░░░░░░ (arriving → at rest → departing)
╰─ falloff triggers again
Flight 2: ╭────────╮ (guide curve flight)
╰──────────────╯
Object C: ████████████████████████ (arriving → final)
// Vellum Pin Stiffness Animation:
// Frame 1-30: stiffness = 1000 (pinned to source)
// Frame 30-60: stiffness = 0 (free flight)
// Frame 60-90: stiffness = 1000 (pinned to target)
v@v drives velocity-based motion blur in Karma/Mantraf@material_id = rand(@ptnum)i@id = @ptnum for per-coin cryptomatteorient, pscale, v💡 >10K coins? Use render-time instancing instead of Copy to Points: Object Merge the points, then set the Geometry OBJ → Render tab → Instance to coin_geo. Zero memory per instance.
| Parameter | Description | Default |
|---|---|---|
t1_start | Transition 1 start time | 1.0 sec |
t1_duration | Transition 1 length | 2.0 sec |
t2_start | Transition 2 start time | 5.0 sec |
t2_duration | Transition 2 length | 2.0 sec |
stagger_max | Max delay from falloff | 1.5 sec |
guide_influence_1/2 | Guide curve pull strength | 0.7-1.0 |
turb_amp | Flight turbulence | 0.3 |
turb_freq | Turbulence frequency | 2.0 |
tumble_amount | Coin rotation during flight | 2.0 |
scale_bulge | Scale pulse mid-flight | 0.15 |
arc_height | How high coins fly (2-stage) | 3-5 units |
curl_amp | Curl noise amplitude (2-stage) | 0.5 |
coin_thickness | Stacking height | Match your coin |
packing_efficiency | Surface fill ratio | 0.75 |
falloff_radius | Falloff sphere size | 5-10 units |
noise_mix | Object/noise falloff blend | 0.3 |
| Function | Usage |
|---|---|
dihedral(v1, v2) | Quaternion rotating v1 to v2 — surface alignment |
slerp(q1, q2, t) | Spherical interpolation between quaternions |
qmultiply(q1, q2) | Combine two quaternion rotations |
quaternion(angle, axis) | Create quaternion from angle+axis — tumble |
primuv(geo, attr, prim, uv) | Sample attribute at UV on curve — guide curves |
xyzdist(geo, pos, prim, uv) | Find closest primitive — curve assignment |
nearpoint(geo, pos) | Find nearest point — point matching |
curlnoise(pos) | Divergence-free noise — turbulence |
anoise(...) | Alligator noise — organic falloff |
lerp(a, b, t) | Linear interpolation — position blending |
fit(val, ...) | Remap value range |
chramp(name, val) | Sample a ramp parameter — easing |
sin(x * PI) | Peaks at 0.5 — mid-flight maximum effects |