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:
- Where do the coins go? — Scatter points on each object. Same count on all. Each point knows its position on every object.
- 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.
- 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.
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
| Item | What to use | Why |
|---|---|---|
| Object A | Sphere, radius 1.0 | Simple curved surface, easy to scatter on |
| Object B | Torus, radius 0.8 | Different size = test stacking later |
| Position | Both at origin | Objects in same space = need guide curves |
| Coin size | radius 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 — Type: Polygon, Sides: 32, Radius:
ch("coin_size")(default 0.05). Orientation: ZX plane (so coins face up along Y). Promotecoin_sizeas a parameter. - PolyExtrude SOP — Depth:
ch("coin_thickness")(default 0.01). Output Back: ON, Output Front: ON. Promotecoin_thickness. - 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:
// 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:
// 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:
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_Aexist 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:
// 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:
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:
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:
// 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:
- Arc — Coins don't fly in a straight line. They arc upward. We use a quadratic Bézier: start → midpoint (raised) → end.
- Tumble — Coins spin during flight. We add rotation around the flight axis, peaking mid-flight.
- 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):
// ══════════════════════════════════════════════════ // 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_heightvalues — 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:
// 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:
// ── 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:
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_radiusso the ripple covers the entire object - Adjust
stagger_maxuntil 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
- 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.
- Resample SOP — Even spacing, ~50-100 points per curve. This ensures
primuvsampling is smooth. - 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:
// 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):
// ── 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 = 0it 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.
// 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:
// 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:
// 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:
// ══════════════════════════════════════════════════════ // 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,vshould 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 blur —
v@vdrives 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
| Problem | Cause | Fix |
|---|---|---|
| Coins fly to random positions | Different point counts on scatters | Force Total Count to the same N on all Scatter SOPs |
| All coins face the same direction | Missing p@orient attribute | Add the orient wrangle from Section 5 |
| Coins don't move at all | Morph wrangle not reading the right inputs | Check input wiring — Object A into 0, guides into 1, falloff into the right input |
| Coins overlap on the target | No stacking applied, surface too small | Add the stack_coins wrangle from Section 10 |
| Transition happens all at once | Missing or zero stagger_max | Set stagger_max to 1.0-2.0 and verify f@falloff exists |
| Coins fly through the object | Guide curves don't arc outward | Redraw guide curves to arc away from center, or increase arc_height |
| Orientation flips at 180° | dihedral ambiguity | Add an upvector: use dihedral({0,0,1}, N) then apply qmultiply with dihedral({0,1,0}, up) |
| Motion blur looks wrong | Missing v@v or incorrect velocity | Store v@prev_P at the start of the wrangle, compute v@v = @P - v@prev_P at the end |
| Performance is slow | Too many points or high-poly coin | Reduce 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 ends | t2_start too close to t1_start + t1_duration | Ensure t2_start > t1_start + t1_duration + stagger_max |
14. Parameter Reference
Starting values
These defaults work for a scene with objects ~2 units across:
| Parameter | What it does | Start with | Tune range |
|---|---|---|---|
t1_start | When transition 1 begins | 1.0 | 0 - 5 sec |
t1_duration | How long transition 1 takes | 2.0 | 1 - 4 sec |
t2_start | When transition 2 begins | 5.0 | After t1 + t1_dur + stagger |
t2_duration | How long transition 2 takes | 2.0 | 1 - 4 sec |
stagger_max | Max falloff delay per coin | 1.5 | 0.5 - 3.0 sec |
guide_influence | How much coins follow curves | 0.8 | 0.3 - 1.0 |
turb_amp | Flight turbulence strength | 0.3 | 0.1 - 1.0 |
turb_freq | Turbulence spatial frequency | 2.0 | 1.0 - 5.0 |
tumble_amount | Coin spin during flight | 2.0 | 0.5 - 5.0 |
scale_bulge | Size increase mid-flight | 0.15 | 0.0 - 0.3 |
arc_height | Bézier arc peak (2-stage) | 3.0 | 1.0 - 8.0 |
falloff_radius | Ripple reach distance | 5.0 | 2.0 - 15.0 |
coin_thickness | Coin depth (for stacking) | 0.01 | Match your coin_geo |
📖 Key VEX Functions Reference
| Function | What it does | Used for |
|---|---|---|
dihedral(v1, v2) | Quaternion rotating v1 → v2 | Surface alignment |
slerp(q1, q2, t) | Spherical quaternion interp | Orientation morph |
qmultiply(q1, q2) | Combine rotations | Adding tumble |
quaternion(angle, axis) | Rotation from angle + axis | Tumble creation |
primuv(geo, attr, prim, uv) | Sample attribute on curve | Guide curve position |
xyzdist(geo, pos, prim, uv) | Find closest primitive | Curve assignment |
nearpoint(geo, pos) | Find nearest point | Point matching |
curlnoise(pos) | Divergence-free noise | Flight turbulence |
lerp(a, b, t) | Linear interpolation | Position blending |
fit(val, src_min, src_max, dst_min, dst_max) | Remap value range | Falloff computation |
chramp(name, val) | Sample ramp parameter | Custom easing curves |
sin(x * PI) | 0→1→0, peaks at x=0.5 | Mid-flight effects |