Coin Object Transformation

Houdini 21 — Procedural point cloud morphing with guide curves, falloff stagger, and coin stacking

Houdini 21 VEX Procedural Point Cloud Morph Guide Curves Coin Stacking SOPs

1. Overview & Concepts

What You're Building

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.

FactorSOPs + VEXAPEX
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

Key Concepts

2. Coin Asset Creation

Node: 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                       
└──────────────────────────────────────────┘

Detailed SOP Settings

Circle SOP:

PolyExtrude SOP:

PolyBevel SOP:

💡 Tip: Keep the coin low-poly (32 sides is fine). You're instancing thousands — polygon count multiplies fast.

3. Source & Target Meshes + Scatter

Calculate Coin Count Per Object

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 SOPs

Scatter SOP Settings (all three):

⚠️ Critical: All scatter distributions must have the EXACT same point count. This is non-negotiable for the morph to work properly.

4. Orient Attributes

Wrangle: 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

Wrangle: set_orient_B

v@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;

Wrangle: set_orient_C

v@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.

5. Point Matching

Option A: Nearest-Point (Simple)

int target_pt = nearpoint(1, @P);
v@target_pos = point(1, "P", target_pt);
p@target_orient = point(1, "orient", target_pt);

Option B: Sort-Based (Better — avoids clumping)

// 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);

Option C: Shuffled (Organic)

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);

3-Stage: Match All Three

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);

6. Falloff-Driven Stagger

Option A: Object Falloff (Recommended)

// 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);

Option B: Noise-Based 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);

Option C: Combine Both (Best Results)

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"));

SDF Volume Falloff (Maximum Control)

// 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);

7. Guide Curves

Setup

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

Assign Coins to Guide Curves

// 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);

Sample Position Along Guide Curve

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);

Entry/Exit U Values

// 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;

8. The Master Morph

2-Stage Version (A → B)

// ==========================================
// 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 Version (A → B → C) — Full

// ============================================================
// 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;

9. Coin Stacking

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;
}

10. Complete Node Network

Object A Mesh e.g. Sphere Object B Mesh e.g. Cube Object C Mesh e.g. Torus Scatter SOP Force Count: N Scatter SOP Force Count: N Scatter SOP Force Count: N Wrangle: orient_A dihedral + pos_A Wrangle: orient_B dihedral + pos_B Wrangle: orient_C dihedral + pos_C Merge SOP all 3 point clouds Wrangle: match_points nearpoint / sort-based matching Guide Curves A→B Curve SOPs + Resample Wrangle: assign_guide_curves curve_id + entry/exit U for transition 1 Guide Curves B→C Curve SOPs + Resample Wrangle: assign_guide_curves_2 curve_id + entry/exit U for transition 2 Falloff Object Animated Null / SDF Wrangle: compute_falloff object distance + noise → f@falloff Wrangle: stack_coins offset excess points along N Wrangle: master_morph 3-phase blend · falloff stagger · guide curves · tumble Copy to Points → RENDER coin_geo Circle→Extrude→Bevel

11. Timing Diagram

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)

12. Vellum Physics Alternative

// 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)

13. Rendering & Polish

Copy to Points Settings

💡 >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.

14. Parameter Reference

ParameterDescriptionDefault
t1_startTransition 1 start time1.0 sec
t1_durationTransition 1 length2.0 sec
t2_startTransition 2 start time5.0 sec
t2_durationTransition 2 length2.0 sec
stagger_maxMax delay from falloff1.5 sec
guide_influence_1/2Guide curve pull strength0.7-1.0
turb_ampFlight turbulence0.3
turb_freqTurbulence frequency2.0
tumble_amountCoin rotation during flight2.0
scale_bulgeScale pulse mid-flight0.15
arc_heightHow high coins fly (2-stage)3-5 units
curl_ampCurl noise amplitude (2-stage)0.5
coin_thicknessStacking heightMatch your coin
packing_efficiencySurface fill ratio0.75
falloff_radiusFalloff sphere size5-10 units
noise_mixObject/noise falloff blend0.3

Key VEX Functions

FunctionUsage
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