Skip to main content

Command Palette

Search for a command to run...

2.4 - Simple Vertex Deformations

Updated
36 min read
2.4 - Simple Vertex Deformations

What We're Learning

So far in our journey, we've learned how to take a mesh - a static, rigid collection of vertices - and move, rotate, and scale it as a single unit. We've mastered the transformation pipeline that places our objects in the world and projects them onto our screen. But what if we want our objects to do more than just move? What if we want them to live, breathe, and react?

This is where vertex deformation comes in. It is the art and science of manipulating the individual vertices of a mesh in real-time, directly on the GPU. Instead of treating a mesh as a rigid statue, we treat it as a malleable digital clay, changing its very shape every single frame.

This technique is one of the most powerful and efficient tools in a graphics programmer's arsenal. While the CPU would have to laboriously loop through every vertex one by one, the GPU can process thousands or even millions of them simultaneously. It's like having an army of tiny, perfectly synchronized robots, each responsible for moving a single point on your model's surface. The result is the ability to create complex, organic motion - like waving flags, rippling water, or pulsating creatures - with just a few lines of mathematical code.

By the end of this chapter, you will have a practical understanding of this fundamental technique. You will learn:

  • How to use sine waves to create waving and rippling effects.

  • How to implement uniform scaling to create pulsating and breathing animations.

  • The critical difference between deforming an object in its own local space versus in world space.

  • How to correctly update surface normals to ensure lighting remains realistic on a changing surface.

  • How to use the @builtin(instance_index) to give unique behaviors to many copies of the same mesh.

  • The performance implications of vertex shader operations and how to keep your effects running smoothly.

  • How to synthesize all these concepts into a complete, interactive pulsating sphere animation.

The Fundamentals of Vertex Deformation

At its heart, vertex deformation is a beautifully simple concept. For every single vertex in a mesh, we apply one formula:

Deformed Position = Original Position + Offset

The Original Position is the vertex data read directly from the mesh buffer - the static, unchanging blueprint of your model. The magic lies in how we calculate the Offset. This offset is a vec3<f32> that we compute on-the-fly in the vertex shader, pushing the vertex in a specific direction to change the model's shape.

The function to calculate this offset is the creative core of our effect. It can be based on anything we have access to in the vertex shader:

  • The vertex's own position (e.g., vertices higher up move more).

  • A uniform value like time to create animation.

  • Built-in values like instance_index for per-object variation.

  • Mathematical functions like sin() to create waves.

// Read the original position from the mesh attribute.
let original_position = in.position;

// Calculate an offset vector. This is where the magic happens!
let offset = calculate_offset(original_position, material.time, other_params);

// Apply the offset to get the new, deformed position.
let deformed_position = original_position + offset;

// Now, continue with the rest of the transformation pipeline.
let model_matrix = mesh_functions::get_world_from_local(in.instance_index);
let world_position = model_matrix * vec4<f32>(deformed_position, 1.0);
// ...and so on.

The Vertex Deformation Pipeline

To understand where this logic fits, let's update the transformation pipeline we've learned so far. Deformation is a new step that happens right at the beginning, after reading the vertex data but before applying any matrix transformations.

1. Read vertex attributes from mesh (local/model space)
   ↓
2. APPLY DEFORMATION (still in local space)  <-- We are here!
   ↓
3. Transform to world space (using the model matrix)
   ↓
4. Transform to view space (using the view matrix)
   ↓
5. Transform to clip space (using the projection matrix)

Key Insight: For most effects, we perform deformations in local space. Think of it this way: you are modifying the object's fundamental blueprint before you place it in the world. The pulsation of a sphere is part of the sphere's nature, regardless of where it is. A flag's waving motion is relative to its flagpole, not its world coordinates.

By deforming in local space, the effect becomes an intrinsic part of the object that moves, rotates, and scales with it naturally. We will explore the crucial distinction between local and world-space deformations in detail later in this article. For now, know that local space is our default and most powerful starting point.

Simple Sine Wave Deformation

The "Hello, World!" of vertex deformation is the sine wave. If you've ever seen a flag wave, water ripple, or a piece of cloth gently sway in a game, you've likely seen the sin() function at work. It is the cornerstone of procedural animation because it produces a smooth, predictable, and endlessly repeating oscillation that looks natural and is incredibly cheap for the GPU to calculate.

The Basic Sine Wave

Let's start by making a flat plane mesh wave up and down. The core logic is a single line of code added to our vertex shader.

@vertex
fn vertex(
    // ... other inputs
    @location(0) position: vec3<f32>,
) -> VertexOutput {
    var out: VertexOutput;

    // --- Start of Deformation ---
    var deformed_position = position;

    // Displace the Y coordinate of the vertex based on its X position and time.
    deformed_position.y += sin(position.x * 3.0 + material.time * 2.0) * 0.2;
    // --- End of Deformation ---

    // Continue with the standard MVP transformation pipeline
    let model = mesh_functions::get_world_from_local(in.instance_index);
    let world_position = model * vec4<f32>(deformed_position, 1.0);
    out.clip_position = position_world_to_clip(world_position.xyz);

    // ... set other outputs
    return out;
}

Let's break down that one magical line: deformed_position.y += sin(position.x * 3.0 + material.time * 2.0) * 0.2;

  • sin(...): This function takes an input number and returns a smooth value that oscillates between -1.0 and 1.0.

  • position.x * 3.0: We feed the vertex's own x coordinate into the sine function. This means the height of the wave depends on where the vertex is along the X-axis. The multiplier (3.0) is the frequency - it controls how many wave crests appear across the mesh.

  • + material.time * 2.0: We add the elapsed time to the input. As time increases, the whole wave pattern shifts, creating the illusion of movement. The multiplier (2.0) is the speed.

  • * 0.2: We take the result of sin() (which is between -1.0 and 1.0) and scale it. This is the amplitude - it controls how high and low the wave peaks are.

  • deformed_position.y += ...: We apply this final calculated offset only to the y component of the position, making the mesh wave up and down.

Visually, this turns a flat line of vertices into a smoothly oscillating curve.

Understanding Sine Wave Parameters

To master vertex animation, you need to develop an intuition for how each part of the sine formula affects the final result. The general formula is:

offset = amplitude * sin(frequency * input_position + speed * time + phase)

Let's visualize what each parameter does:

Amplitude

This is the scaler applied after the sin() function. It controls the intensity or "height" of the wave.

**// Small, subtle waves (low amplitude)
let offset = sin(...) * 0.1;

// Big, dramatic waves (high amplitude)
let offset = sin(...) * 0.5;**

Frequency

This is the scaler applied to the vertex position inside the sin() function. It controls the density or "tightness" of the waves.

// A few broad waves (low frequency)
let offset = sin(position.x * 1.0 + time) * 0.2;

// Many tight waves (high frequency)
let offset = sin(position.x * 10.0 + time) * 0.2;

Speed

This is the scaler applied to time. It controls how fast the wave pattern moves across the mesh.

// A slow, gentle wave (low speed)
let offset = sin(position.x * 3.0 + time * 0.5) * 0.2;

// A fast, energetic wave (high speed)
let offset = sin(position.x * 3.0 + time * 5.0) * 0.2;

Phase

This is a constant offset added inside the sin() function. It doesn't change the shape of the wave, only its starting position. This is incredibly useful for making multiple objects animate out of sync, which we'll see later with instance_index.

// Shift the wave's starting point
let phase_offset = 1.57; // ~PI / 2, or a 90-degree shift
let offset = sin(position.x * 3.0 + time + phase_offset) * 0.2;

Building Complexity

You can create far more interesting effects by combining these simple building blocks.

Multi-Directional Waves

What happens if you calculate a wave based on position.x and another based on position.z and add them together? You get a beautiful interference pattern, exactly like ripples intersecting on the surface of a pond.

// A wave moving along the X-axis
let wave_x = sin(position.x * 3.0 + time * 2.0) * 0.1;

// A wave moving along the Z-axis with a different speed and frequency
let wave_z = sin(position.z * 4.0 + time * 1.5) * 0.1;

// The final offset is the sum of both contributions
deformed_position.y += wave_x + wave_z;

Directional Displacement

The deformation offset is a vector. We've only been modifying the .y component, but we can modify any axis we want to create different kinds of motion.

// Displace horizontally to create a shearing effect
deformed_position.x += sin(position.y * 5.0 + time) * 0.2;

// Create a twist effect by rotating XZ coordinates based on height (Y)
let twist_angle = position.y * 2.0 + time;
let radius = length(position.xz); // Preserve original radius from center
deformed_position.x = cos(twist_angle) * radius;
deformed_position.z = sin(twist_angle) * radius;

// Create a radial pulse that expands from the center Y-axis
let distance_from_center = length(position.xz);
let pulse = sin(distance_from_center * 5.0 - time * 3.0) * 0.1;
deformed_position.y += pulse;

By creatively combining these simple mathematical tools, you can produce a massive variety of organic and compelling visual effects.


## Scaling from Center

Another core deformation technique is scaling vertices relative to the model's origin. Instead of adding an offset, we **multiply** the vertex position by a scalar value. This pushes vertices further away from (or pulls them closer to) the center, making the object appear to grow, shrink, or breathe.

### Simple Uniform Scaling

Uniform scaling applies the same scaling factor to all axes (`x`, `y`, and `z`), preserving the object's proportions. This is the basis for creating a simple pulsating effect.

```rust
// Calculate a scale factor that oscillates between 0.8 and 1.2 over time.
// sin() returns -1 to 1. 
// Multiplying by 0.2 gives -0.2 to 0.2. 
// Adding 1.0 shifts the range to 0.8 to 1.2.
let scale = 1.0 + sin(material.time * 2.0) * 0.2;

// Multiply the entire position vector by the scale factor.
let deformed_position = position * scale;

This simple operation creates a powerful pulsating effect. Since we are in local space, the position vector represents the direction from the model's center (0,0,0) to the vertex. Multiplying it scales the vertex along that very direction, creating a perfect expansion from the origin.

Non-Uniform Scaling

Non-uniform scaling applies different scaling factors to different axes, which allows you to stretch and squash the mesh. This is a classic animation principle used to give motion a sense of weight and flexibility.

A common technique is to approximate volume preservation. If you stretch an object along one axis, you should squash it along the others. A simple way to do this is to make the scaling factors for the other axes inversely proportional to the main axis.

// Calculate a primary scaling factor for the Y-axis.
let scale_y = 1.0 + sin(time * 2.0) * 0.3; // Stretches and squashes vertically

// Calculate a reciprocal factor for the XZ-plane to approximate volume conservation.
// If scale_y > 1, then 1/sqrt(scale_y) < 1, and vice-versa.
let scale_xz = 1.0 / sqrt(scale_y);

// Apply the different scale factors to each component.
var deformed_position = position;
deformed_position.y *= scale_y;
deformed_position.xz *= scale_xz;

This creates a much more organic and satisfying "bouncing" or "breathing" animation than simple uniform scaling.

Distance-Based Scaling

You can create more nuanced effects by making the scaling factor depend on the vertex's own properties, such as its distance from the model's origin. This can, for instance, make the outer parts of a mesh "breathe" while the core remains relatively stable.

// Get the vertex's distance from the model's center (0,0,0 in local space).
let distance = length(position);

// Create a scaling effect that is stronger for vertices farther from the center.
let scale_factor = sin(time * 2.0) * (distance * 0.3);
let scale = 1.0 + scale_factor;

// Apply the scaling.
let deformed_position = position * scale;

Axis-Aligned Scaling

Sometimes, you may want to scale an object along an arbitrary direction, not just the primary X, Y, or Z axes. This can be achieved using the dot product to project the vertex position onto a direction vector.

// Define the axis along which we want to scale (e.g., diagonally).
let scale_axis = normalize(vec3<f32>(1.0, 1.0, 0.0));

// Calculate how much of the vertex's position lies along our chosen axis.
// This gives the distance from the origin along the scale_axis.
let projection = dot(position, scale_axis);

// Decompose the position into two parts: 
// 1. A vector parallel to the axis.
let parallel_component = projection * scale_axis;
// 2. A vector perpendicular to the axis (the remainder).
let perpendicular_component = position - parallel_component;

// Define our scaling amount over time.
let scale_amount = 1.0 + sin(time * 2.0) * 0.5;

// Scale only the parallel component, then add the perpendicular part back.
let deformed_position = perpendicular_component + parallel_component * scale_amount;

This advanced technique gives you precise control to stretch or squash a mesh in any direction you can define.

Local vs. World Space Deformations

So far, we have been working exclusively in local space. This was a deliberate and important choice. Now, we must understand why we made that choice and when we might want to do things differently. The coordinate space in which you apply your deformation is a fundamental creative decision. It answers the question: Is this effect part of the object itself, or is it part of the world the object inhabits?

Local Space Deformations: "The Effect is part of the Object"

Think of deforming in local space like being a sculptor working on a piece of clay that sits on a rotating pottery wheel. You shape the clay, adding waves and bulges. The shape you create is intrinsic to the sculpture. When you're done, you can pick the sculpture up, move it to another room, and place it on a shelf at any angle - the shape you sculpted remains perfectly intact, moving and rotating with the object.

This is what happens when we deform before applying the model matrix.

@vertex
fn vertex(in: VertexInput) -> VertexOutput {
    var out: VertexOutput;

    // 1. Deform in local space FIRST. We are the sculptor.
    var deformed_local = in.position;
    deformed_local.y += sin(in.position.x * 3.0 + time) * 0.2;

    // 2. THEN, transform the finished sculpture into the world.
    let model = mesh_functions::get_world_from_local(in.instance_index);
    let world_position = model * vec4<f32>(deformed_local, 1.0);

    // ... continue to clip space ...
    out.clip_position = position_world_to_clip(world_position.xyz);
    return out;
}

The Result: The wave pattern is "baked into" the object's own coordinate system. If you rotate the object, the wave pattern rotates with it. If you move it, the wave moves with it.

Use Cases (when the effect is intrinsic to the object):

  • A character's breathing animation.

  • The waving motion of a flag attached to a moving vehicle.

  • The pulsation of a magical orb.

  • The jiggle of a gelatinous cube.

World Space Deformations: "The Effect is part of the World"

Now, imagine taking your finished, static statue and placing it in a river. The flowing water will push and pull at the statue's surface, making it appear to distort. The statue itself isn't changing, but an external, environmental force is acting upon it. The effect is tied to the location, not the object. If you place another statue in the same spot, it will be distorted by the river in the exact same way.

This is what happens when we apply the model matrix first and then deform the resulting world position.

@vertex
fn vertex(in: VertexInput) -> VertexOutput {
    var out: VertexOutput;

    // 1. Transform to world space FIRST. Place the static object in the environment.
    let model = mesh_functions::get_world_from_local(in.instance_index);
    let world_position = model * vec4<f32>(in.position, 1.0);

    // 2. THEN, deform its world position. The environment acts on the object.
    var deformed_world = world_position;
    deformed_world.y += sin(world_position.x * 3.0 + time) * 0.2;

    // ... continue to clip space ...
    out.clip_position = position_world_to_clip(deformed_world.xyz);
    return out;
}

The Result: The wave pattern is fixed in world space. As an object moves through it, the wave appears to flow across the object's surface. Two different objects at the same world coordinates will be deformed identically.

Use Cases (when the effect is environmental):

  • The surface of a large body of water where multiple objects (boats, debris) should ripple in sync.

  • A fixed force field or magical barrier that distorts things passing through it.

  • A global wind effect that makes all trees and grass in a certain area sway in the same direction.

  • A shockwave from an explosion that expands outwards from a point in the world.

Hybrid Approaches

You can combine these techniques for more nuanced control. For example, you could have a world-space effect (a river) whose strength is modulated by a local-space property (the height of the vertex on the object). This would make the bottom of a boat be affected by the water, while the mast remains untouched.

// 1. Get the vertex's world position.
let world_position = model * vec4<f32>(in.position, 1.0);

// 2. Calculate the world-space effect.
let world_wave = sin(world_position.x * 3.0 + time) * 0.2;

// 3. Modulate the effect's strength by a local property.
//    Here, we use smoothstep to create a falloff.
//    Vertices with local y > 1.0 are unaffected.
let local_influence = 1.0 - smoothstep(0.0, 1.0, in.position.y);

// 4. Apply the final, modulated deformation.
var deformed_world = world_position;
deformed_world.y += world_wave * local_influence;

Comparison Table

AspectLocal Space DeformationWorld Space Deformation
Transform OrderDeform → Model → View → ClipModel → Deform → View → Clip
Object RotationThe effect rotates with the object.The effect remains world-aligned; the object moves through it.
Multiple InstancesEach instance is deformed relative to its own center.All instances are affected by the same global deformation field.
PerformanceGenerally faster. Calculations are simpler and inputs are smaller.Can be slightly slower. Requires a matrix multiply before deformation.
Primary Use CaseObject-centric effects: breathing, pulsing, intrinsic motion.Environmental effects: water, wind, force fields.

Maintaining Proper Normals

We've successfully changed the shape of our mesh. But in doing so, we've created a new, subtle problem: our lighting is now incorrect.

A mesh's normals are vectors that define the orientation of the surface at each vertex. They tell the lighting engine which way the surface is facing, which is essential for calculating how light reflects off it. The core rule is that a normal vector must always be perpendicular to the surface at that vertex's location.

When we deform the mesh, we change the slope and curvature of that surface. However, the original normals stored in the mesh buffer are not automatically updated. The shader will continue to use the old, now-incorrect normal, leading to lighting that looks flat and wrong, completely breaking the illusion of a dynamic 3D shape.

Let's visualize the problem step-by-step, looking at a small patch of the mesh.

(A) Original Flat Surface

On the original mesh, the vertices form a flat plane. The stored normal vector is perpendicular to this plane, so the lighting is correct.

(B) Deformed Surface with an Incorrect Normal

After we apply a deformation, the vertices move, creating a sloped surface. But the shader, by default, still uses the original normal, which is no longer perpendicular to the new surface.

The lighting engine sees a vertical normal and calculates light as if the surface were still flat. This is wrong and breaks the illusion of shape. The highlight will appear "painted on" rather than belonging to the new geometry.

(C) Deformed Surface with the Correct Normal

To fix this, we must calculate a new normal in our vertex shader that is perpendicular to the newly deformed surface.

With this corrected normal, lighting calculations will produce realistic highlights and shadows that perfectly match the new, dynamic shape of our mesh.

There are several strategies to compute this new normal, ranging from fast approximations to more complex, accurate calculations.

Strategy 1: Approximating Normals with Derivatives

For deformations based on a mathematical function (like our sine wave), we can use a fast approximation based on the function's derivative. The derivative tells us the slope of the new surface at any given point. We can use this slope to "nudge" the original normal so that it points in a more plausible direction.

Let's look at our sine wave: offset_y = amplitude * sin(frequency * position.x + time). The derivative of this function with respect to x tells us the slope along the X-axis:

slope_x = amplitude * frequency * cos(frequency * position.x + time)
// A wave deforms our position's Y based on its X.
let frequency = 3.0;
let amplitude = 0.2;
let time = material.time;
let wave_input = position.x * frequency + time;
deformed_position.y += sin(wave_input) * amplitude;

// The derivative of sin is cos. This gives us the slope along X.
let slope = cos(wave_input) * frequency * amplitude;

// The original normal for a flat plane is (0, 1, 0).
// A vector representing the new slope is roughly (-slope, 1, 0).
// We can construct this new direction and normalize it.
// This is NOT a physically accurate calculation, but a fast, plausible-looking
// approximation that works well for gentle waves.
let perturbed_normal = normalize(vec3<f32>(-slope, 1.0, 0.0));

Important: This method is an approximation. Its main advantage is that it is extremely fast, adding only a few extra math operations per vertex. It works best for gentle, wave-like deformations where the original surface is mostly flat.

Strategy 2: Geometric Normal Recalculation (More Accurate, More Expensive)

A more robust and accurate method is to regenerate the normal geometrically. The principle is simple: if we know the deformed position of a vertex and the deformed positions of its immediate neighbors, we can define the new surface and calculate its true normal.

We can simulate this by sampling our deformation function at three nearby points: the original position, a point slightly offset on the X-axis, and a point slightly offset on the Z-axis.

// This function encapsulates our deformation logic.
fn deform(p: vec3<f32>, time: f32) -> vec3<f32> {
    var deformed = p;
    deformed.y += sin(p.x * 3.0 + time) * 0.2; // Our sine wave
    return deformed;
}

fn recalculate_normal_geometrically(position: vec3<f32>, time: f32) -> vec3<f32> {
    let epsilon = 0.001; // A very small offset

    // Calculate the deformed position at the center and two nearby points.
    let center_pos = deform(position, time);
    let neighbor_x_pos = deform(position + vec3<f32>(epsilon, 0.0, 0.0), time);
    let neighbor_z_pos = deform(position + vec3<f32>(0.0, 0.0, epsilon), time);

    // Create two tangent vectors on the new surface.
    let tangent_x = neighbor_x_pos - center_pos;
    let tangent_z = neighbor_z_pos - center_pos;

    // The cross product of the tangents gives the new surface normal.
    // The direction might need to be flipped (-cross) depending on winding order.
    return normalize(cross(tangent_z, tangent_x));
}

This method is far more accurate and works for almost any deformation function, no matter how complex. The downside is its performance cost: we are running our deformation logic three times for every single vertex. This can be a significant performance hit and should be used judiciously, perhaps only on hero assets or when accuracy is paramount.

When Can You Skip Normal Updates?

In some situations, you can get away with not updating the normals at all:

  1. Unlit or Emissive Materials: If an object is not affected by lighting (e.g., it's a hologram, a UI element, or pure fire), its normals are irrelevant.

  2. Very Subtle Deformations: If the offset is tiny, the error in the normals will be visually negligible.

  3. Stylized Shading: For flat-shading or toon-shading styles, precise normals are often less important than the overall silhouette and color bands.

  4. Level of Detail (LOD): A common optimization is to use accurate recalculated normals for objects close to the camera, but switch to the cheaper original normals (or a faster approximation) for objects in the distance where the lighting error won't be noticeable.

Choosing the right strategy is a classic trade-off between visual quality and performance. For most simple deformations, a fast approximation will be sufficient.

Per-Object Variation with instance_index

So far, if we render ten waving flags using our shader, they will all wave in perfect, robotic synchronization. This instantly breaks the illusion of natural motion. To make a world feel alive, we need variety. Each object should have its own unique character.

The GPU provides a simple but powerful tool to solve this: the @builtin(instance_index). This is a special WGSL input that gives us a unique, zero-based integer ID for each copy of a mesh being rendered. If you draw 100 spheres, the first sphere's vertex shader will receive an instance_index of 0, the second will get 1, and so on.

A Look Ahead: The mechanism that allows the GPU to draw many objects so efficiently is called instanced rendering. It's a deep and important performance topic that we will dedicate all of article 2.7 - Instanced Rendering to. For now, all you need to know is that instance_index gives us a unique ID for each object, which we can use to break up the uniformity of our effects.

Phase Offsetting

The simplest way to use instance_index is to create a phase offset for our time-based animations. Instead of every object using the exact same time value, each gets a small offset, making them animate out of sync.

@vertex
fn vertex(
    @builtin(instance_index) instance_index: u32,
    // ...
) -> VertexOutput {
    // Each instance gets a slightly different starting point in the sine wave.
    let phase_offset = f32(instance_index) * 0.5;

    var deformed_position = position;
    deformed_position.y += sin(position.x * 3.0 + material.time + phase_offset) * 0.2;
    // ...
}

Result: Instead of a synchronized army, you get a field of objects moving with a natural, chaotic offset.

Deterministic Randomness

We can also use the instance_index to generate consistent, "random-looking" numbers to vary properties like size or color. We do this with a simple hash function, which turns an ordered sequence of IDs (0, 1, 2...) into a jumbled but repeatable sequence of values.

// A simple hash function that takes a u32 and returns a float between 0.0 and 1.0.
fn hash(index: u32) -> f32 {
    let n = f32(index) * 12.9898;
    return fract(sin(n) * 43758.5453);
}

@vertex
fn vertex(
    @builtin(instance_index) instance_index: u32,
    // ...
) -> VertexOutput {
    // Each instance gets its own unique but consistent "random" value.
    let random_val = hash(instance_index);

    // Use this value to vary multiple properties of the deformation.
    let frequency = mix(3.0, 5.0, random_val); // Vary frequency
    let amplitude = mix(0.1, 0.3, random_val); // Vary amplitude

    // ... apply deformation using these unique values ...
}

By using instance_index as a seed for variation, you can transform a single, repetitive effect into a rich and believable scene.

Performance Considerations

Vertex shaders are executed for every single vertex of a mesh, every single frame. A model with 50,000 vertices running at 60 FPS requires the GPU to run your vertex shader code 3,000,000 times per second for that object alone. While modern GPUs are incredibly fast, it's important to be aware that complex calculations in the vertex shader can become a performance bottleneck.

The core principle of vertex shader optimization is to do as little work as possible.

  • Any calculation that is the same for every single vertex (e.g., based only on time) should be done once on the CPU and passed to the shader as a uniform.

  • Avoid complex logic for objects that are far away from the camera, as the detail will be lost anyway.

A Look Ahead: Performance tuning is a deep and critical subject in graphics programming. We will dedicate all of article 2.8 - Vertex Shader Optimization to a thorough exploration of these concepts, covering topics like branch divergence, mathematical optimizations, and profiling tools. For now, focus on getting your effects working correctly; we will learn how to make them fast in a later chapter.


Complete Example: Pulsating Sphere System

It's time to put theory into practice. We will build a complete Bevy application that demonstrates all the core concepts of this chapter: multiple deformation types, per-instance variation, correct normal updates, and real-time uniform controls.

Our Goal

Our goal is to create a 5x5 grid of spheres. Each sphere will be animated using a single custom shader, but thanks to per-instance variation, each will have a unique color and animation phase. We will add keyboard controls to switch between different deformation modes (Pulsate, Wave, Twist) and adjust their parameters, like speed and amplitude, on the fly.

What This Project Demonstrates

  • Shader Uniforms: How to control shader effects from Rust by updating a uniform struct (PulsatingSphereUniforms).

  • Vertex Deformation: Implementing multiple, distinct deformation techniques within a single vertex shader.

  • Normal Maintenance: Calculating updated normals for each deformation type to ensure lighting remains correct.

  • Per-Instance Variation: Using @builtin(instance_index) to give each sphere a unique color and animation offset, making the scene feel organic.

  • Conditional Logic in Shaders: Using a deformation_mode uniform to switch between different code paths in the shader.

  • Fragment Shader Basics: Applying simple lighting and using data passed from the vertex shader (like a per-instance color) to shade the final pixel.

The Shader (assets/shaders/d02_04_pulsating_sphere.wgsl)

This is the heart of our visual effect. The WGSL code is divided into two main parts:

  1. @vertex Shader: This is where all the deformation logic lives. It reads the incoming vertex data, checks the deformation_mode uniform, and calls the appropriate function (apply_pulsate, apply_wave, etc.) to calculate the new vertex position. Critically, it also calculates the corresponding deformed_normal for that mode and passes both the final position and normal to the fragment shader. It also uses a hash function on the instance_index to generate a unique color for each sphere.

  2. @fragment Shader: This part is simpler. It receives the interpolated world position, the corrected world normal, and the unique instance color from the vertex stage. It then performs basic Blinn-Phong lighting calculations to give the spheres a sense of volume and uses the instance color as the base albedo. It also adds a small emissive highlight based on the strength of the deformation.

#import bevy_pbr::mesh_functions
#import bevy_pbr::view_transformations::position_world_to_clip

struct PulsatingSphereUniforms {
    time: f32,
    pulse_speed: f32,
    pulse_amplitude: f32,
    deformation_mode: u32,  // 0=pulsate, 1=wave, 2=twist, 3=combined
    wave_frequency: f32,
    twist_amount: f32,
    camera_position: vec3<f32>,
    _padding: f32,
}

@group(2) @binding(0)
var<uniform> material: PulsatingSphereUniforms;

struct VertexInput {
    @builtin(instance_index) instance_index: u32,
    @location(0) position: vec3<f32>,
    @location(1) normal: vec3<f32>,
    @location(2) uv: vec2<f32>,
}

struct VertexOutput {
    @builtin(position) clip_position: vec4<f32>,
    @location(0) world_position: vec3<f32>,
    @location(1) world_normal: vec3<f32>,
    @location(2) deformation_amount: f32,
    @location(3) instance_color: vec3<f32>,
}

// Simple hash for per-instance variation
fn hash_instance(index: u32) -> f32 {
    let n = f32(index) * 12.9898;
    return fract(sin(n) * 43758.5453);
}

// Get color based on instance index
fn get_instance_color(index: u32) -> vec3<f32> {
    let hue = hash_instance(index);

    // Simple HSV to RGB conversion for varied colors
    let sat = 0.7;
    let val = 0.9;

    let c = val * sat;
    let x = c * (1.0 - abs((hue * 6.0) % 2.0 - 1.0));
    let m = val - c;

    var rgb = vec3<f32>(0.0);
    if hue < 1.0 / 6.0 {
        rgb = vec3<f32>(c, x, 0.0);
    } else if hue < 2.0 / 6.0 {
        rgb = vec3<f32>(x, c, 0.0);
    } else if hue < 3.0 / 6.0 {
        rgb = vec3<f32>(0.0, c, x);
    } else if hue < 4.0 / 6.0 {
        rgb = vec3<f32>(0.0, x, c);
    } else if hue < 5.0 / 6.0 {
        rgb = vec3<f32>(x, 0.0, c);
    } else {
        rgb = vec3<f32>(c, 0.0, x);
    }

    return rgb + vec3<f32>(m);
}

// Mode 0: Simple pulsating
fn apply_pulsate(
    position: vec3<f32>,
    normal: vec3<f32>,
    time: f32,
    instance_index: u32,
) -> vec3<f32> {
    // Each instance pulses at slightly different phase
    let phase = f32(instance_index) * 0.3;

    // Pulse uniformly in all directions
    let pulse = sin(time * material.pulse_speed + phase) * material.pulse_amplitude;
    let scale = 1.0 + pulse;

    return position * scale;
}

// Mode 1: Sine wave deformation
fn apply_wave(
    position: vec3<f32>,
    normal: vec3<f32>,
    time: f32,
    instance_index: u32,
) -> vec3<f32> {
    let phase = f32(instance_index) * 0.5;

    var deformed = position;

    // Wave propagates based on distance from center
    let dist = length(position.xz);
    let wave = sin(dist * material.wave_frequency - time * material.pulse_speed + phase);

    // Displace along normal for organic look
    deformed += normal * wave * material.pulse_amplitude;

    return deformed;
}

// Mode 2: Twist deformation
fn apply_twist(
    position: vec3<f32>,
    normal: vec3<f32>,
    time: f32,
    instance_index: u32,
) -> vec3<f32> {
    let phase = f32(instance_index) * 0.4;

    // Twist amount varies with time
    let twist = sin(time * material.pulse_speed + phase) * material.twist_amount;

    // Twist increases with height
    let angle = position.y * twist;

    let cos_a = cos(angle);
    let sin_a = sin(angle);

    var deformed = position;
    deformed.x = position.x * cos_a - position.z * sin_a;
    deformed.z = position.x * sin_a + position.z * cos_a;

    return deformed;
}

// Mode 3: Combined effects
fn apply_combined(
    position: vec3<f32>,
    normal: vec3<f32>,
    time: f32,
    instance_index: u32,
) -> vec3<f32> {
    var deformed = position;

    // Base pulsation
    let phase = f32(instance_index) * 0.3;
    let pulse = sin(time * material.pulse_speed * 0.5 + phase) * material.pulse_amplitude * 0.5;
    deformed = deformed * (1.0 + pulse);

    // Add wave detail
    let dist = length(position.xz);
    let wave = sin(dist * material.wave_frequency * 2.0 - time * material.pulse_speed) * material.pulse_amplitude * 0.3;
    deformed += normal * wave;

    // Slight twist
    let twist = sin(time * material.pulse_speed * 0.3) * material.twist_amount * 0.5;
    let angle = position.y * twist;
    let cos_a = cos(angle);
    let sin_a = sin(angle);
    let x = deformed.x * cos_a - deformed.z * sin_a;
    let z = deformed.x * sin_a + deformed.z * cos_a;
    deformed.x = x;
    deformed.z = z;

    return deformed;
}

// Calculate perturbed normal for wave deformation
fn calculate_wave_normal(
    position: vec3<f32>,
    normal: vec3<f32>,
    time: f32,
    instance_index: u32,
) -> vec3<f32> {
    let phase = f32(instance_index) * 0.5;
    let dist = length(position.xz);

    // Calculate gradient of wave
    let wave_gradient = cos(dist * material.wave_frequency - time * material.pulse_speed + phase)
        * material.wave_frequency;

    // Direction of gradient
    let gradient_dir = normalize(vec3<f32>(position.x, 0.0, position.z));

    // Perturb normal
    let tangent_offset = gradient_dir * wave_gradient * material.pulse_amplitude;

    return normalize(normal + tangent_offset * 0.5);
}

@vertex
fn vertex(in: VertexInput) -> VertexOutput {
    var out: VertexOutput;

    // Apply deformation based on mode
    var deformed_position: vec3<f32>;
    var deformed_normal = in.normal;
    var deformation_amount = 0.0;

    if material.deformation_mode == 0u {
        // Pulsate
        deformed_position = apply_pulsate(in.position, in.normal, material.time, in.instance_index);
        let phase = f32(in.instance_index) * 0.3;
        deformation_amount = sin(material.time * material.pulse_speed + phase) * material.pulse_amplitude;
        // Normals don't need adjustment for uniform scaling
    } else if material.deformation_mode == 1u {
        // Wave
        deformed_position = apply_wave(in.position, in.normal, material.time, in.instance_index);
        deformed_normal = calculate_wave_normal(in.position, in.normal, material.time, in.instance_index);
        let dist = length(in.position.xz);
        let phase = f32(in.instance_index) * 0.5;
        deformation_amount = sin(dist * material.wave_frequency - material.time * material.pulse_speed + phase)
            * material.pulse_amplitude;
    } else if material.deformation_mode == 2u {
        // Twist
        deformed_position = apply_twist(in.position, in.normal, material.time, in.instance_index);
        // For twist, normal also needs to rotate
        let phase = f32(in.instance_index) * 0.4;
        let twist = sin(material.time * material.pulse_speed + phase) * material.twist_amount;
        let angle = in.position.y * twist;
        let cos_a = cos(angle);
        let sin_a = sin(angle);
        deformed_normal.x = in.normal.x * cos_a - in.normal.z * sin_a;
        deformed_normal.z = in.normal.x * sin_a + in.normal.z * cos_a;
        deformation_amount = abs(twist) * material.twist_amount;
    } else {
        // Combined
        deformed_position = apply_combined(in.position, in.normal, material.time, in.instance_index);
        // Use wave normal as approximation
        deformed_normal = calculate_wave_normal(in.position, in.normal, material.time, in.instance_index);
        deformation_amount = 0.5; // Middle value for combined
    }

    // Transform to world space
    let model = mesh_functions::get_world_from_local(in.instance_index);
    let world_position = mesh_functions::mesh_position_local_to_world(
        model,
        vec4<f32>(deformed_position, 1.0)
    );

    let world_normal = mesh_functions::mesh_normal_local_to_world(
        deformed_normal,
        in.instance_index
    );

    // Transform to clip space
    out.clip_position = position_world_to_clip(world_position.xyz);
    out.world_position = world_position.xyz;
    out.world_normal = normalize(world_normal);
    out.deformation_amount = deformation_amount;
    out.instance_color = get_instance_color(in.instance_index);

    return out;
}

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let normal = normalize(in.world_normal);

    // Lighting
    let light_dir = normalize(vec3<f32>(1.0, 1.0, 1.0));
    let view_dir = normalize(material.camera_position - in.world_position);

    // Diffuse
    let diffuse = max(0.0, dot(normal, light_dir)) * 0.7;

    // Simple specular
    let half_vec = normalize(light_dir + view_dir);
    let specular = pow(max(0.0, dot(normal, half_vec)), 32.0) * 0.3;

    // Ambient
    let ambient = 0.3;

    // Base color from instance
    let base_color = in.instance_color;

    // Modulate by deformation amount
    let deform_highlight = abs(in.deformation_amount) * 0.3;
    let final_color = base_color * (ambient + diffuse) + vec3<f32>(specular) + vec3<f32>(deform_highlight);

    return vec4<f32>(final_color, 1.0);
}

The Rust Material (src/materials/d02_04_pulsating_sphere.rs)

This file defines the bridge between our Bevy application and our shader. It contains the PulsatingSphereUniforms struct, which precisely matches the layout of the uniform block in our shader. The PulseSphereMaterial struct wraps these uniforms and implements Bevy's Material and AsBindGroup traits, making it a usable asset.

use bevy::prelude::*;
use bevy::render::render_resource::{AsBindGroup, ShaderRef};

mod uniforms {
    #![allow(dead_code)]

    use bevy::prelude::*;
    use bevy::render::render_resource::ShaderType;

    #[derive(ShaderType, Debug, Clone)]
    pub struct PulsatingSphereUniforms {
        pub time: f32,
        pub pulse_speed: f32,
        pub pulse_amplitude: f32,
        pub deformation_mode: u32,
        pub wave_frequency: f32,
        pub twist_amount: f32,
        pub camera_position: Vec3,
        pub _padding: f32,
    }

    impl Default for PulsatingSphereUniforms {
        fn default() -> Self {
            Self {
                time: 0.0,
                pulse_speed: 2.0,
                pulse_amplitude: 0.3,
                deformation_mode: 0,
                wave_frequency: 3.0,
                twist_amount: 2.0,
                camera_position: Vec3::ZERO,
                _padding: 0.0,
            }
        }
    }
}

pub use uniforms::PulsatingSphereUniforms;

#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct PulseSphereMaterial {
    #[uniform(0)]
    pub uniforms: PulsatingSphereUniforms,
}

impl Material for PulseSphereMaterial {
    fn vertex_shader() -> ShaderRef {
        "shaders/d02_04_pulsating_sphere.wgsl".into()
    }

    fn fragment_shader() -> ShaderRef {
        "shaders/d02_04_pulsating_sphere.wgsl".into()
    }
}

Don't forget to add it to src/materials/mod.rs:

// ... other materials
pub mod d02_04_pulsating_sphere;

The Demo Module (src/demos/d02_04_pulsating_sphere.rs)

This Rust file sets up our Bevy scene. It registers our custom material, spawns a grid of Sphere meshes, and assigns our PulseSphereMaterial to each one. It includes systems to update the uniforms every frame based on time and user input, rotate the camera, and display a UI showing the current settings.

use crate::materials::d02_04_pulsating_sphere::{PulsatingSphereUniforms, PulseSphereMaterial};
use bevy::prelude::*;
use std::f32::consts::PI;

pub fn run() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::<PulseSphereMaterial>::default())
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (update_time, handle_input, rotate_camera, update_ui),
        )
        .run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<PulseSphereMaterial>>,
) {
    // Create a grid of pulsating spheres
    let grid_size = 5;
    let spacing = 3.0;

    for x in 0..grid_size {
        for z in 0..grid_size {
            let pos_x = (x as f32 - grid_size as f32 / 2.0) * spacing;
            let pos_z = (z as f32 - grid_size as f32 / 2.0) * spacing;

            commands.spawn((
                Mesh3d(meshes.add(Sphere::new(1.0).mesh().uv(32, 16))),
                MeshMaterial3d(materials.add(PulseSphereMaterial {
                    uniforms: PulsatingSphereUniforms::default(),
                })),
                Transform::from_xyz(pos_x, 0.0, pos_z),
            ));
        }
    }

    // Lighting
    commands.spawn((
        DirectionalLight {
            illuminance: 10000.0,
            shadows_enabled: false,
            ..default()
        },
        Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -PI / 4.0, PI / 4.0, 0.0)),
    ));

    // Camera
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(0.0, 12.0, 18.0).looking_at(Vec3::ZERO, Vec3::Y),
    ));

    // UI
    commands.spawn((
        Text::new(
            "[1-4] Deformation Mode | [Q/W] Pulse Speed | [A/S] Amplitude\n\
             [Z/X] Wave Frequency | [C/V] Twist Amount\n\
             \n\
             Mode: Pulsate | Speed: 2.0 | Amplitude: 0.3",
        ),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(10.0),
            left: Val::Px(10.0),
            ..default()
        },
        TextFont {
            font_size: 16.0,
            ..default()
        },
    ));
}

fn update_time(
    time: Res<Time>,
    camera_query: Query<&Transform, With<Camera3d>>,
    mut materials: ResMut<Assets<PulseSphereMaterial>>,
) {
    let Ok(camera_transform) = camera_query.single() else {
        return;
    };
    let camera_pos = camera_transform.translation;

    for (_, material) in materials.iter_mut() {
        material.uniforms.time = time.elapsed_secs();
        material.uniforms.camera_position = camera_pos;
    }
}

fn handle_input(
    keyboard: Res<ButtonInput<KeyCode>>,
    time: Res<Time>,
    mut materials: ResMut<Assets<PulseSphereMaterial>>,
) {
    let delta = time.delta_secs();

    for (_, material) in materials.iter_mut() {
        // Switch deformation mode
        if keyboard.just_pressed(KeyCode::Digit1) {
            material.uniforms.deformation_mode = 0; // Pulsate
        }
        if keyboard.just_pressed(KeyCode::Digit2) {
            material.uniforms.deformation_mode = 1; // Wave
        }
        if keyboard.just_pressed(KeyCode::Digit3) {
            material.uniforms.deformation_mode = 2; // Twist
        }
        if keyboard.just_pressed(KeyCode::Digit4) {
            material.uniforms.deformation_mode = 3; // Combined
        }

        // Adjust pulse speed
        if keyboard.pressed(KeyCode::KeyQ) {
            material.uniforms.pulse_speed = (material.uniforms.pulse_speed - delta * 2.0).max(0.1);
        }
        if keyboard.pressed(KeyCode::KeyW) {
            material.uniforms.pulse_speed = (material.uniforms.pulse_speed + delta * 2.0).min(10.0);
        }

        // Adjust pulse amplitude
        if keyboard.pressed(KeyCode::KeyA) {
            material.uniforms.pulse_amplitude =
                (material.uniforms.pulse_amplitude - delta * 0.5).max(0.0);
        }
        if keyboard.pressed(KeyCode::KeyS) {
            material.uniforms.pulse_amplitude =
                (material.uniforms.pulse_amplitude + delta * 0.5).min(1.0);
        }

        // Adjust wave frequency
        if keyboard.pressed(KeyCode::KeyZ) {
            material.uniforms.wave_frequency =
                (material.uniforms.wave_frequency - delta * 2.0).max(0.5);
        }
        if keyboard.pressed(KeyCode::KeyX) {
            material.uniforms.wave_frequency =
                (material.uniforms.wave_frequency + delta * 2.0).min(10.0);
        }

        // Adjust twist amount
        if keyboard.pressed(KeyCode::KeyC) {
            material.uniforms.twist_amount = (material.uniforms.twist_amount - delta).max(0.0);
        }
        if keyboard.pressed(KeyCode::KeyV) {
            material.uniforms.twist_amount = (material.uniforms.twist_amount + delta).min(5.0);
        }
    }
}

fn rotate_camera(time: Res<Time>, mut camera_query: Query<&mut Transform, With<Camera3d>>) {
    for mut transform in camera_query.iter_mut() {
        let angle = time.elapsed_secs() * 0.2;
        let radius = 18.0;
        let height = 12.0;

        transform.translation.x = angle.cos() * radius;
        transform.translation.z = angle.sin() * radius;
        transform.translation.y = height;

        transform.look_at(Vec3::ZERO, Vec3::Y);
    }
}

fn update_ui(materials: Res<Assets<PulseSphereMaterial>>, mut text_query: Query<&mut Text>) {
    if !materials.is_changed() {
        return;
    }

    if let Some((_, material)) = materials.iter().next() {
        let mode_name = match material.uniforms.deformation_mode {
            0 => "Pulsate",
            1 => "Wave",
            2 => "Twist",
            3 => "Combined",
            _ => "Unknown",
        };

        for mut text in text_query.iter_mut() {
            **text = format!(
                "[1-4] Deformation Mode | [Q/W] Pulse Speed | [A/S] Amplitude\n\
                 [Z/X] Wave Frequency | [C/V] Twist Amount\n\
                 \n\
                 Mode: {} | Speed: {:.1} | Amplitude: {:.2}\n\
                 Wave Freq: {:.1} | Twist: {:.1}",
                mode_name,
                material.uniforms.pulse_speed,
                material.uniforms.pulse_amplitude,
                material.uniforms.wave_frequency,
                material.uniforms.twist_amount,
            );
        }
    }
}

Don't forget to add it to src/demos/mod.rs:

// ... other demos
pub mod d02_04_pulsating_sphere;

And register it in src/main.rs:

Demo {
    number: "2.4",
    title: "Simple Vertex Deformations",
    run: demos::d02_04_pulsating_sphere::run,
},

Running the Demo

When you run the application, you will see a grid of 25 spheres, each with a different vibrant color. They will initially be in "Pulsate" mode, gently breathing in and out, but with each sphere slightly out of sync with its neighbors. Use the keyboard controls to explore the different effects.

Controls

KeysAction
1-4Switch Deformation Mode (1: Pulsate, 2: Wave, 3: Twist, 4: Combined)
Q / WDecrease / Increase animation speed
A / SDecrease / Increase animation amplitude/strength
Z / XDecrease / Increase wave frequency (for Wave mode)
C / VDecrease / Increase twist amount (for Twist mode)

What You're Seeing

ModeWhat to ObserveConcept Illustrated
1: PulsateThe spheres grow and shrink uniformly from their centers. Note that the specular highlight on top remains perfectly stable and round.Uniform Scaling. Because this deformation preserves the direction of normals, no complex normal recalculation is needed.
2: WaveA ripple effect expands from the center of each sphere and travels down its surface. Watch the specular highlight - it realistically stretches and moves with the surface curvature.Normal-based Displacement & Normal Recalculation. The displacement is along the normal for an organic look, and the normals are perturbed using the derivative of the sine wave to keep lighting correct.
3: TwistThe spheres twist around their vertical axis. The highlight and shading correctly wrap around the twisted shape.Rotational Deformation. Both vertex positions and normals are rotated around the Y-axis, with the angle of rotation based on the vertex's height.
4: CombinedA complex, chaotic motion that layers all three effects.Composition. Simple, independent deformations can be added together to create rich and complex-looking effects without needing to write entirely new logic.
All ModesNotice that no two spheres are animating in perfect sync, and each has a unique color.Per-Instance Variation. The instance_index is used to create a phase offset for the animation and to seed a hash function for the color, bringing life and variety to the scene.

Key Takeaways

You have now taken a significant step from simply moving objects around to fundamentally reshaping them on the GPU. This is a cornerstone of modern real-time graphics. Before moving on to the next chapter, let's solidify the core concepts you've learned.

  1. Vertex Deformation is position + offset: At its core, all vertex deformation is the process of calculating an offset vector and adding it to the original vertex position. The creativity lies in how you calculate that offset.

  2. Deform in Local Space: For effects that are intrinsic to an object (pulsing, breathing, waving), always perform deformations in local space before applying the Model-View-Projection matrices. This ensures the effect scales, rotates, and moves correctly with the object.

  3. Sine Waves are Your Friend: The sin() function is the workhorse of procedural animation. By mastering its parameters - amplitude, frequency, and speed - you can create a vast array of organic, oscillating motions.

  4. Don't Forget the Normals: Changing a vertex's position changes the surface. If you don't update the vertex's normal to match, your lighting will be incorrect. This is one of the most common mistakes beginners make.

  5. instance_index Creates Variety: Use the @builtin(instance_index) to break up robotic synchronization. It is the key to making a scene feel natural and alive by giving each object a unique animation phase, color, or behavior.

  6. Performance Matters: Vertex shaders run millions of times per second. Be mindful of expensive operations. As we'll see in a later article, moving calculations that are constant for all vertices to the CPU is a key optimization.

  7. Simple Effects Compose: Complex motion can be achieved by layering multiple simple effects. A pulsation combined with a twist and a wave creates a result that is far more interesting than any of its individual parts.

What's Next?

You now have the power to bring your static meshes to life with procedural motion. We've mastered the smooth, predictable power of sine waves, but the world isn't always so orderly. In the next chapter, we will build on these fundamentals by exploring more complex and organic displacement techniques. We'll learn how to use noise functions and texture lookups to create less uniform and more natural-looking surface details, like billowing flags and detailed terrain.

Next up: 2.5 - Advanced Vertex Displacement


Quick Reference

Basic Sine Wave

Displaces a vertex's y coordinate based on its x coordinate and time.

deformed_position.y += sin(position.x * frequency + time * speed) * amplitude;

Uniform Scaling

Multiplies the entire position vector to expand or shrink the mesh from its center.

let scale = 1.0 + sin(time) * amount;
deformed_position = position * scale;

Radial Wave

Creates a ripple that expands from the local Y-axis.

let distance = length(position.xz);
deformed_position.y += sin(distance * frequency - time * speed) * amplitude;

Twist

Rotates the XZ plane of a vertex based on its height (y coordinate).

let angle = position.y * twist_amount * sin(time);
let cos_a = cos(angle);
let sin_a = sin(angle);
let new_x = position.x * cos_a - position.z * sin_a;
let new_z = position.x * sin_a + position.z * cos_a;
deformed_position.xz = vec2<f32>(new_x, new_z);

Per-Instance Randomness

Generates a consistent "random" float between 0.0 and 1.0 for each instance.

fn hash(index: u32) -> f32 {
    let n = f32(index) * 12.9898;
    return fract(sin(n) * 43758.5453);
}
let random_val = hash(instance_index);

The Deformation Pipeline Order

The canonical order of operations for a vertex shader with local-space deformation.

// 1. Read original vertex attributes (position, normal).
let original_pos = in.position;
let original_normal = in.normal;

// 2. Apply deformation logic (in local space).
let deformed_pos = apply_deformation(original_pos, time);
let deformed_normal = calculate_new_normal(original_pos, original_normal, time);

// 3. Transform position and normal to world space using Bevy's helpers.
let model = mesh_functions::get_world_from_local(in.instance_index);
let world_pos = mesh_functions::mesh_position_local_to_world(model, vec4<f32>(deformed_pos, 1.0));
let world_normal = mesh_functions::mesh_normal_local_to_world(deformed_normal, in.instance_index);

// 4. Transform to clip space for the rasterizer.
let clip_pos = position_world_to_clip(world_pos.xyz);