📐 1. The Big Picture

What you'll build

Thousands of coins scatter on one object, then fly through the air and settle onto a different object. The transformation ripples outward from a point you control. If the target is smaller, extra coins stack on top. For a 3-object setup, they transform twice: A → B → C.

How it actually works (in plain English)

Think of it as three separate problems that stack on top of each other:

  1. Where do the coins go? — Scatter points on each object. Same count on all. Each point knows its position on every object.
  2. How do they fly? — Instead of moving in a straight line from A to B, coins follow hand-drawn guide curves. This is what makes it look cinematic instead of mechanical.
  3. When does each coin move? — Not all at once. A falloff controls the stagger: coins near the falloff center move first, distant coins follow. This creates the ripple effect.
Object A Scatter Store Positions Morph Wrangle Copy to Points Render

Why SOPs + VEX, not APEX

APEX is for KineFX character rigging — transform hierarchies with bones. Coin morphing is a point cloud instancing problem. You'd need one TransformObject per coin (thousands = impractical). SOPs + VEX gives you scatter, wrangle, Copy to Points, and Vellum — all the tools this effect needs natively.

How this tutorial is structured

We build the effect in layers. Each section adds one concept and you can test after every step:

  • Sections 2-6: Get points moving from A to B with a basic lerp (functional but boring)
  • Section 7: The morph wrangle that makes it actually look good (arc + tumble + scale pulse)
  • Section 8: Stagger the timing so coins don't all move at once
  • Section 9: Guide curves so coins follow artistic paths instead of straight lines
  • Section 10: Handle overflow coins when the target is smaller
  • Section 11: Extend to 3 objects (A → B → C)

💡 The key pattern: Almost every "cinematic" effect in this tutorial uses sin(blend * PI). This function equals 0 at the start and end of a transition, and peaks at 1.0 right in the middle. That means guide pull, turbulence, tumble, and scale pulse all hit maximum when the coin is mid-flight — exactly when you want it — and smoothly fade to zero when the coin lands. You'll see this pattern over and over.

🎬 2. Scene Setup

Create your objects

Start with 2 objects. A sphere and a torus work great for testing. Position them at the origin, overlapping or nearly overlapping.

💡 Scene scale matters. If your objects are 2-3 units across, a coin radius of 0.05 works. If your scene is 10x bigger, scale the coin up too. A coin that's too small means you'll need way more points, and the morph gets slower.

Recommended test setup

ItemWhat to useWhy
Object ASphere, radius 1.0Simple curved surface, easy to scatter on
Object BTorus, radius 0.8Different size = test stacking later
PositionBoth at originObjects in same space = need guide curves
Coin sizeradius 0.05, thickness 0.01~800-2000 coins per object (fast enough to iterate)

Name your objects clearly in the network: object_A, object_B. You'll be wiring them into wrangles and it gets confusing fast if they're called geo1, geo2.

🪙 3. Coin Asset

Build the coin geometry

Create a subnetwork or HDA called coin_geo. This is the geometry that gets instanced on every point.

Circle SOP PolyExtrude PolyBevel Null → OUT
  1. Circle SOP — Type: Polygon, Sides: 32, Radius: ch("coin_size") (default 0.05). Orientation: ZX plane (so coins face up along Y). Promote coin_size as a parameter.
  2. PolyExtrude SOP — Depth: ch("coin_thickness") (default 0.01). Output Back: ON, Output Front: ON. Promote coin_thickness.
  3. PolyBevel SOP — Offset: ch("edge_bevel") (default 0.002). This catches light on the edges — without it, coins look like flat discs.

💡 Why 32 sides? At a distance, 32 looks smooth. Up close it's slightly faceted — which actually looks good for coins. Going higher (64, 128) doubles/triples the poly count per instance. With 2000 coins × 32 sides = 64K tris. With 128 sides = 256K tris. Keep it low.

4. Scatter Points

The core idea

Each object gets a Scatter SOP. The scattered points become the coin positions. For the morph to work, every Scatter must output the same number of points. If Object A needs 1000 coins and Object B only needs 600, you still scatter 1000 on both — the extra 400 on B will get stacked later.

How to calculate the count

Add a Detail Wrangle on each mesh to calculate how many coins fit:

VEX · Detail Wrangle
// Run this on each mesh to get its coin count
float surface_area = getbbox_size(0).x * getbbox_size(0).z;
float coin_footprint = ch("coin_size")**2 * 4.0;  // ≈ πr²
int coin_count = ceil(surface_area / coin_footprint * ch("packing_density"));
setdetailattrib(0, "coin_count", coin_count, "set");

Find the largest coin_count from all your objects — that becomes your N. Set Force Total Count = N on every Scatter SOP.

Scatter SOP settings

  • Force Total Count: N (same on ALL scatters — this is non-negotiable)
  • Prim Num Attribute: ON, name primnum
  • Prim UVW Attribute: ON, name primuvw
  • Relax Iterations: 2-3 (more even distribution)

⚠️ Most common beginner mistake: Different point counts on the scatters. If Object A has 1000 points and Object B has 800, the morph will look completely broken — coins will fly to random positions. Always force the same count.

✅ Checkpoint: Scatter

  • Each Scatter SOP shows the same point count in the node info
  • Points are distributed evenly across each surface (not clustered)
  • Display points on each scatter — you should see them covering the surface

🔄 5. Orient & Store Positions

Why we need orient

p@orient is a quaternion that tells Copy to Points how to rotate each coin. Without it, all coins face the same direction regardless of the surface. With it, each coin aligns to its surface normal — so coins on a sphere curve around it naturally.

The dihedral trick

dihedral(v1, v2) returns a quaternion that rotates vector v1 to vector v2. Our coins are built on the ZX plane, so their "up" is {0,1,0}. But Copy to Points uses the Z axis as the default alignment, so we rotate from {0,0,1} to the surface normal:

VEX · Point Wrangle — set_orient_A
// Align coin to surface normal + store position for morphing
v@N = normalize(v@N);
vector4 q = dihedral({0,0,1}, v@N);
p@orient = q;
p@orient_A = q;   // ← Remember A's orientation for the morph
v@pos_A = @P;     // ← Remember A's position for the morph
f@pscale = 1.0;

Create separate wrangles for B (and C if using 3 objects). Same code, just change _A to _B / _C:

VEX · Point 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;

Why store pos_A and orient_A as separate attributes?

Because later we'll merge all point clouds into one, and each point needs to remember "where I was on Object A" AND "where I need to go on Object B." These stored attributes are the morph's source and destination data.

✅ Checkpoint: Orient

  • Plug each scatter into a Copy to Points with coin_geo — coins should align to the surface, not all face the same way
  • On a sphere, coins should curve around it like scales
  • Check the Geometry Spreadsheet: orient_A, pos_A exist on Object A's points

🔗 6. Match Points Across Objects

The problem

After scatter, point 0 on Object A is in a random spot, and point 0 on Object B is in a different random spot. For a clean morph, we need each coin on A to know which coin on B it should fly to.

The simplest approach that works: Sort-Based Matching

Use Houdini 21's Attribute Sort SOP to sort both point clouds the same way (e.g., by position along X). After sorting, point 0 on both clouds is the leftmost point, point 1 is the next, etc. Then matching is trivial:

VEX · Point Wrangle — match_points
// After Attribute Sort sorts both clouds the same way:
// Point @ptnum on A maps to point @ptnum on B
v@pos_B = point(1, "P", @ptnum);
p@orient_B = point(1, "orient", @ptnum);

Wire Object A's sorted points into input 0, Object B's sorted points into input 1. That's it.

📖 Other matching methods (when you need them)

Nearest-Point matching — simpler to set up but causes clumping. Coins near each other on A all fly to the same area on B:

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

Shuffled matching — adds randomness to avoid clumping while keeping it organic:

VEX · shuffled matching
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);

When to use which: Sort-based for clean, even transitions. Nearest-point for quick prototyping. Shuffled when you want organic randomness without clumping.

✅ Checkpoint: Matching

  • In the Geometry Spreadsheet, each point should have pos_A, orient_A, pos_B, orient_B
  • Visualize: create a Wrangle that sets @P = lerp(v@pos_A, v@pos_B, ch("blend")) — slide the blend from 0 to 1. Coins should move smoothly from A positions to B positions (no crossing/overlapping paths if sort-based)

🎯 7. Basic Morph (A → B)

Start with a simple lerp — get it working first

Before adding arcs and guide curves, make sure the basic blend works. This is your sanity check:

VEX · Point Wrangle — basic_lerp (test only)
// Simple test: blend from A to B over time
float t = @Time;
float start = chf("start_time");   // default: 1.0
float dur = chf("duration");      // default: 2.0
float blend = clamp((t - start) / dur, 0, 1);

// Position: lerp
@P = lerp(v@pos_A, v@pos_B, blend);

// Orientation: slerp (spherical lerp for quaternions)
p@orient = slerp(p@orient_A, p@orient_B, blend);

f@pscale = 1.0;

If this works — coins slide from A to B — your scatter, orient, and matching are all correct. Now let's make it look good.

Make it cinematic: Arc + Tumble + Scale Pulse

Three things separate a "meh" morph from a cinematic one:

  1. Arc — Coins don't fly in a straight line. They arc upward. We use a quadratic Bézier: start → midpoint (raised) → end.
  2. Tumble — Coins spin during flight. We add rotation around the flight axis, peaking mid-flight.
  3. Scale pulse — Coins slightly grow mid-flight and shrink back on landing. Subtle but adds weight.

All three effects use the sin(blend * PI) pattern — it equals 0 at the endpoints and peaks at blend=0.5 (mid-flight):

VEX · Point Wrangle — cinematic_morph
// ══════════════════════════════════════════════════
// CINEMATIC 2-STAGE MORPH — Arc + Tumble + Pulse
// ══════════════════════════════════════════════════

float t = @Time;
float start = chf("start_time");   // 1.0
float dur = chf("duration");      // 2.0
float blend = clamp((t - start) / dur, 0, 1);
blend = chramp("blend_curve", blend);  // ease in/out

// ── ARC ──
// Quadratic Bézier: A → midpoint (raised) → B
vector src = v@pos_A;
vector tgt = v@pos_B;
vector mid = lerp(src, tgt, 0.5);
float arc = ch("arc_height");           // default: 3.0
float arc_var = noise(@ptnum * 5.678) * ch("arc_variation");  // per-coin randomness
mid += {0, 1, 0} * (arc + arc_var);  // raise midpoint up

// Quadratic Bézier interpolation
vector a = lerp(src, mid, blend);
vector b = lerp(mid, tgt, blend);
@P = lerp(a, b, blend);

// ── TUMBLE ──
// Spin coins during flight, max spin at midpoint
p@orient = slerp(p@orient_A, p@orient_B, blend);
if (blend > 0 && blend < 1) {
    float tumble = sin(blend * 3.14159) * ch("tumble_amount");  // peaks mid-flight
    vector t_axis = normalize(tgt - src + 0.001);
    p@orient = qmultiply(p@orient, quaternion(radians(tumble * 360), t_axis));
}

// ── SCALE PULSE ──
// Coins grow slightly mid-flight
float pulse = sin(blend * 3.14159) * ch("scale_bulge");  // default: 0.15
f@pscale = 1.0 + pulse;

// ── VELOCITY (for motion blur) ──
v@v = @P - v@pos_A;

🧠 Why Bézier? A straight lerp(A, B, t) moves coins in a line. A quadratic Bézier (lerp(lerp(A,mid,t), lerp(mid,B,t), t)) pulls every coin through a raised midpoint. Combined with arc_height and per-coin arc_variation, you get coins flying at different heights — some low, some high — instead of a uniform arc.

✅ Checkpoint: Basic Morph

  • Coins arc upward during flight, not slide in a straight line
  • Coins tumble (spin) while in the air
  • Coins slightly grow mid-flight and shrink on landing
  • At blend=0 all coins are exactly on Object A, at blend=1 exactly on Object B
  • Try different arc_height values — 1.0 for subtle, 5.0 for dramatic

🌊 8. Falloff Stagger

The problem

Right now all coins move at the same time. That looks robotic. In reality, transformations ripple outward from a point — nearby coins go first, distant coins follow.

How falloff works

We compute a f@falloff attribute for each point, ranging from 0 (transforms first) to 1 (transforms last). Then in the morph wrangle, we add falloff × stagger_max as a delay to each coin's start time.

Object Falloff (the one to start with)

Create a Null SOP at the center of where you want the ripple to start. Animate it if you want the ripple origin to move. Then compute distance from each coin to this null:

VEX · Point Wrangle — compute_falloff
// Input 1 = falloff Null (single point at ripple origin)
vector falloff_pos = point(1, "P", 0);
float dist = length(@P - falloff_pos);

// 0 = transforms first, 1 = transforms last
float radius = ch("falloff_radius");  // how far the ripple reaches
f@falloff = fit(dist, 0, radius, 0, 1);
f@falloff = chramp("falloff_curve", f@falloff);  // shape the falloff

Apply the stagger to the morph

Modify the morph wrangle's timing section. Instead of every coin starting at the same time, each coin gets delayed by its falloff:

VEX · Modified timing section
// ── STAGGERED TIMING ──
float stagger_max = ch("stagger_max");  // how much delay (seconds)
float delay = f@falloff * stagger_max;

float local_start = start + delay;  // each coin starts at a different time
float elapsed = t - local_start;
float blend = clamp(elapsed / dur, 0, 1);
blend = chramp("blend_curve", blend);

💡 Tune stagger_max carefully. Too small (0.1) and coins still look like they move together. Too large (5.0) and the transition takes forever because the last coins haven't started while the first are already done. Start with stagger_max = 1.5 for a 2-second duration.

📖 Noise + Combined Falloff (for organic look)

Object falloff creates a clean radial ripple. For a more organic, chaotic feel, mix in noise:

VEX · Combined falloff
float obj_fall = fit(length(@P - falloff_pos), 0, ch("radius"), 0, 1);
float noise_fall = fit(anoise(@P * ch("noise_freq")), -1, 1, 0, 1);
f@falloff = lerp(obj_fall, noise_fall, ch("noise_mix"));  // 0=pure radial, 1=pure noise

Set noise_mix to ~0.3 for mostly radial with some organic variation.

✅ Checkpoint: Falloff

  • Coins near the falloff null move first, distant coins follow
  • Adjust falloff_radius so the ripple covers the entire object
  • Adjust stagger_max until the stagger looks natural — not too uniform, not too spread
  • Animate the falloff null's position to "sweep" the transformation across the object

〰️ 9. Guide Curves

Why guide curves matter

The Bézier arc from Section 7 gives every coin a similar parabolic path. Guide curves let you art-direct the flight paths — coins can swoop wide, spiral, or follow specific shapes in the air. This is what separates a tech demo from a finished shot.

When your objects overlap (most common case)

If Object A and Object B are at the same position, coins need to fly outward and back — they can't go through each other. Draw curves that arc away from the center:

        ╭─── guide curve ───╮
       /                      \
      /                        \
Object A ←──── same area ────→ Object B

Build the guide curves

  1. Curve SOP — Draw mode: Bezier or NURBS. Draw 3-5 curves that arc outward from the object center. Don't overthink it — you can always add more later.
  2. Resample SOP — Even spacing, ~50-100 points per curve. This ensures primuv sampling is smooth.
  3. Null SOP — Name it guide_curves.

Assign each coin to a guide curve

The best method is spatial proximity — each coin follows the closest guide curve:

VEX · Point Wrangle — assign_guides
// Input 1 = guide curves
int closest_prim;
vector closest_uv;
xyzdist(1, @P, closest_prim, closest_uv);  // find nearest curve
i@guide_curve = closest_prim;

// Each coin travels a random section of its assigned curve
f@guide_u_entry = 0.2 + rand(@ptnum) * 0.1;
f@guide_u_exit = 0.8 + rand(@ptnum + 99) * 0.1;

Modify the morph to use guide curves

Instead of the Bézier arc, sample position along the guide curve and blend between the direct path and the curve path. The guide influence is strongest mid-flight (there's that sin(blend * PI) pattern again):

VEX · Guide curve section (replace Bézier arc)
// ── GUIDE CURVE PATH ──
float guide_u = lerp(f@guide_u_entry, f@guide_u_exit, blend);
vector guide_pos = primuv(1, "P", i@guide_curve, set(guide_u, 0, 0));

// Blend between direct path and guide curve
float guide_weight = sin(blend * 3.14159) * ch("guide_influence");  // peaks mid-flight
vector direct_path = lerp(v@pos_A, v@pos_B, blend);
@P = lerp(direct_path, guide_pos, guide_weight);

// Add turbulence during flight
if (blend > 0 && blend < 1) {
    vector turb = curlnoise(@P * ch("turb_freq") + t * 0.5);
    @P += turb * sin(blend * 3.14159) * ch("turb_amp");
}

💡 How much guide influence? guide_influence at 0.7-1.0 means coins closely follow the curves. At 0.3 they mostly fly direct with a slight curve pull. Start at 1.0 and dial down if it's too exaggerated.

✅ Checkpoint: Guide Curves

  • Coins follow the hand-drawn curves instead of arcing uniformly
  • Different guide curves create different flight paths for different groups of coins
  • At guide_influence = 0 it falls back to the Bézier arc — good for A/B comparison
  • No coins fly through the object — curves arc outward properly

📚 10. Coin Stacking

When you need it

If Object B is smaller than Object A, it can't fit all the coins on its surface. The scatter placed N points on B, but many of them are crammed together or even overlapping because the surface is too small. Stacking takes the overflow coins and layers them on top.

How it works

On the target's scatter, coins up to base_capacity sit directly on the surface. Every coin after that gets pushed up along the surface normal by one coin thickness per layer. Add XZ jitter so stacked coins don't perfectly overlap.

VEX · Point Wrangle — stack_coins
// Run on the target's scatter points
int base_capacity = ch("base_capacity");  // how many fit on one layer

if (@ptnum < base_capacity) {
    i@layer = 0;      // surface layer, no offset
    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;           // push up along normal
    // Jitter so stacked coins don't perfectly overlap
    vector jitter = set(rand(@ptnum+1)-0.5, 0, rand(@ptnum+2)-0.5);
    @P += jitter * ch("coin_size") * 0.5;
}

💡 How to calculate base_capacity: It's the same calculation from the scatter section — surface area / coin footprint. If your target's natural capacity is 600 but you scattered 1000, then base_capacity = 600, and 400 coins stack in layers of 600.

🔄 11. 3-Stage Morph (A → B → C)

What changes from 2-stage

Everything from Sections 2-9 still applies. The only difference: you have 3 objects, so each point stores pos_A, pos_B, pos_C (and corresponding orients). The morph wrangle runs two transitions with separate timing, and you need a second set of guide curves for B → C.

3-Stage matching

After orienting and storing positions on all 3 objects, match them in a single wrangle that takes inputs from all three:

VEX · Point Wrangle — match_3stage
// Input 0 = Object A points, Input 1 = Object B points, Input 2 = Object C points
// Match A → B → C using nearest point
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);

Two sets of guide curves

Draw one set of curves for the A→B flight path, another for B→C. Assign them separately:

VEX · Point Wrangle — assign_guides_3stage
// Input 1 = guide curves A→B, Input 2 = guide curves B→C
int prim1, prim2;
vector uv1, uv2;
xyzdist(1, @P, prim1, uv1);
xyzdist(2, @P, prim2, uv2);

i@guide1_curve = prim1;
f@guide1_u_entry = 0.2 + rand(@ptnum) * 0.1;
f@guide1_u_exit = 0.8 + rand(@ptnum + 99) * 0.1;

i@guide2_curve = prim2;
f@guide2_u_entry = 0.2 + rand(@ptnum + 200) * 0.1;
f@guide2_u_exit = 0.8 + rand(@ptnum + 299) * 0.1;

The full 3-stage morph wrangle

This combines everything — stagger, guide curves, tumble, scale pulse — for both transitions:

VEX · Point Wrangle — master_morph_3stage
// ══════════════════════════════════════════════════════
// 3-STAGE COIN MORPH — Complete Animation Wrangle
// Input 0 = points, Input 1 = guides A→B, Input 2 = guides B→C
// ══════════════════════════════════════════════════════

float t = @Time;

// ── TIMING ──
float t1_start = chf("t1_start");     // default: 1.0
float t1_dur   = chf("t1_duration");  // default: 2.0
float t2_start = chf("t2_start");     // default: 5.0
float t2_dur   = chf("t2_duration");  // default: 2.0

// ── FALLOFF STAGGER ──
float delay = f@falloff * ch("stagger_max");

// ── TRANSITION 1: A → B ──
float t1 = clamp((t - t1_start - delay) / t1_dur, 0, 1);
t1 = chramp("t1_curve", t1);

// ── TRANSITION 2: B → C ──
float t2 = clamp((t - t2_start - delay) / t2_dur, 0, 1);
t2 = chramp("t2_curve", t2);

// ── GUIDE CURVE SAMPLING ──
float g1_u = lerp(f@guide1_u_entry, f@guide1_u_exit, t1);
vector g1_pos = primuv(1, "P", i@guide1_curve, set(g1_u, 0, 0));
float g2_u = lerp(f@guide2_u_entry, f@guide2_u_exit, t2);
vector g2_pos = primuv(2, "P", i@guide2_curve, set(g2_u, 0, 0));

// ── FLIGHT 1: A → B via guide1 ──
vector flight1;
if (t1 <= 0)      flight1 = v@pos_A;
else if (t1 >= 1) flight1 = v@pos_B;
else {
    float gw = sin(t1 * 3.14159) * ch("guide_influence_1");
    flight1 = lerp(lerp(v@pos_A, v@pos_B, t1), g1_pos, gw);
    flight1 += curlnoise(@P * ch("turb_freq") + t*0.5)
               * sin(t1 * 3.14159) * ch("turb_amp");
}

// ── FLIGHT 2: B → C via guide2 ──
vector flight2;
if (t2 <= 0)      flight2 = v@pos_B;
else if (t2 >= 1) flight2 = v@pos_C;
else {
    float gw = sin(t2 * 3.14159) * ch("guide_influence_2");
    flight2 = lerp(lerp(v@pos_B, v@pos_C, t2), g2_pos, gw);
    flight2 += curlnoise(@P * ch("turb_freq") + t*0.5 + 100)
               * sin(t2 * 3.14159) * ch("turb_amp");
}

// ── COMBINE: use flight2 if transition 2 has started ──
@P = (t2 > 0) ? flight2 : flight1;

// ── ORIENTATION ──
vector4 o1 = slerp(p@orient_A, p@orient_B, t1);
vector4 o2 = slerp(p@orient_B, p@orient_C, t2);
if (t1 > 0 && t1 < 1) {
    float tumble = sin(t1 * 3.14159) * ch("tumble_amount");
    o1 = qmultiply(o1, quaternion(radians(tumble*360),
           normalize(v@pos_B - v@pos_A + 0.001)));
}
if (t2 > 0 && t2 < 1) {
    float tumble = sin(t2 * 3.14159) * ch("tumble_amount");
    o2 = qmultiply(o2, quaternion(radians(tumble*360),
           normalize(v@pos_C - v@pos_B + 0.001)));
}
p@orient = (t2 > 0) ? o2 : o1;

// ── SCALE PULSE ──
float active = (t2 > 0) ? t2 : t1;
f@pscale = ((active > 0 && active < 1) ? 1.0 + sin(active*3.14159) * ch("scale_bulge") : 1.0);

// ── VELOCITY (motion blur) ──
v@v = @P - v@prev_P;
v@prev_P = @P;

Timing for 2 transitions

Set t1_start and t2_start with a gap between them. You want Object B to be "fully formed" before coins start leaving it:

Time ──────────────────────────────────────────────────→

Object A: ████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  (at rest → leaving)
           ╰─ falloff ripple starts

Flight 1:       ╭────────╮                        (guide curves A→B)
                  ╰────────────────╯

Object B:          ░░░░████████████████░░░░░░░░░  (arriving → rest → leaving)
                                           ╰─ second falloff

Flight 2:                          ╭────────╮     (guide curves B→C)
                                      ╰──────────────╯

Object C:                               ████████████████████████  (arriving → final)

🎬 12. Rendering & Polish

Copy to Points

  • Pack Instances: ON — packed primitives are faster and use less memory
  • Verify point instance attributes: orient, pscale, v should all be present on your points

Lighting & materials

  • Coin material — Metallic with slight roughness variation per instance: f@material_id = rand(@ptnum)
  • Ambient occlusion — Crucial for stacked coins. Without it, stacked layers look flat
  • Depth of field — Sells the 3D space during flight
  • Motion blurv@v drives velocity-based motion blur in Karma/Mantra
  • Shadow catcher — Ground plane for shadow contact

💡 >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 — the renderer creates them on the fly.

📖 Vellum Physics Alternative

Want real physics between coins? Use Vellum with animated pin stiffness:

  • Frame 1-30: stiffness = 1000 (pinned to source)
  • Frame 30-60: stiffness = 0 (free flight with collisions)
  • Frame 60-90: stiffness = 1000 (pinned to target)

Tradeoff: Vellum gives you self-collision and natural settling, but it's slower and less art-directable than the procedural approach. Use it when you need coins to physically interact.

🔧 13. Troubleshooting

ProblemCauseFix
Coins fly to random positionsDifferent point counts on scattersForce Total Count to the same N on all Scatter SOPs
All coins face the same directionMissing p@orient attributeAdd the orient wrangle from Section 5
Coins don't move at allMorph wrangle not reading the right inputsCheck input wiring — Object A into 0, guides into 1, falloff into the right input
Coins overlap on the targetNo stacking applied, surface too smallAdd the stack_coins wrangle from Section 10
Transition happens all at onceMissing or zero stagger_maxSet stagger_max to 1.0-2.0 and verify f@falloff exists
Coins fly through the objectGuide curves don't arc outwardRedraw guide curves to arc away from center, or increase arc_height
Orientation flips at 180°dihedral ambiguityAdd an upvector: use dihedral({0,0,1}, N) then apply qmultiply with dihedral({0,1,0}, up)
Motion blur looks wrongMissing v@v or incorrect velocityStore v@prev_P at the start of the wrangle, compute v@v = @P - v@prev_P at the end
Performance is slowToo many points or high-poly coinReduce coin sides to 24-32, use packed instances, or switch to render-time instancing for >10K coins
3-stage: transition 2 starts before transition 1 endst2_start too close to t1_start + t1_durationEnsure t2_start > t1_start + t1_duration + stagger_max

📋 14. Parameter Reference

Starting values

These defaults work for a scene with objects ~2 units across:

ParameterWhat it doesStart withTune range
t1_startWhen transition 1 begins1.00 - 5 sec
t1_durationHow long transition 1 takes2.01 - 4 sec
t2_startWhen transition 2 begins5.0After t1 + t1_dur + stagger
t2_durationHow long transition 2 takes2.01 - 4 sec
stagger_maxMax falloff delay per coin1.50.5 - 3.0 sec
guide_influenceHow much coins follow curves0.80.3 - 1.0
turb_ampFlight turbulence strength0.30.1 - 1.0
turb_freqTurbulence spatial frequency2.01.0 - 5.0
tumble_amountCoin spin during flight2.00.5 - 5.0
scale_bulgeSize increase mid-flight0.150.0 - 0.3
arc_heightBézier arc peak (2-stage)3.01.0 - 8.0
falloff_radiusRipple reach distance5.02.0 - 15.0
coin_thicknessCoin depth (for stacking)0.01Match your coin_geo
📖 Key VEX Functions Reference
FunctionWhat it doesUsed for
dihedral(v1, v2)Quaternion rotating v1 → v2Surface alignment
slerp(q1, q2, t)Spherical quaternion interpOrientation morph
qmultiply(q1, q2)Combine rotationsAdding tumble
quaternion(angle, axis)Rotation from angle + axisTumble creation
primuv(geo, attr, prim, uv)Sample attribute on curveGuide curve position
xyzdist(geo, pos, prim, uv)Find closest primitiveCurve assignment
nearpoint(geo, pos)Find nearest pointPoint matching
curlnoise(pos)Divergence-free noiseFlight turbulence
lerp(a, b, t)Linear interpolationPosition blending
fit(val, src_min, src_max, dst_min, dst_max)Remap value rangeFalloff computation
chramp(name, val)Sample ramp parameterCustom easing curves
sin(x * PI)0→1→0, peaks at x=0.5Mid-flight effects

🎥 Related Tutorials