Think about a snake slithering throughout the display screen, easily chasing your mouse alongside an natural path.
Constructing that path is the problem. Mouse enter is noisy, movement should replace in actual time, and the curve can’t be precomputed or saved forward of time.
1. Introduction
This tutorial introduces a two-part curve system to unravel it. CurveGenerator produces quick cubic Bézier segments steered by behavioral guidelines, whereas EndlessCurve stitches them right into a steady, memory-bounded path. With that in place, we render a procedural snake on prime.
Inspiration
Whereas retro Snake video games, I needed to reimagine the concept in 3D by eradicating grid-based motion and changing discrete steps with easy movement. The consequence turned out to be extra advanced than anticipated, so let’s dive proper in.
2. Producing the curve
Every curve phase begins with a single query: what path ought to the snake transfer in now? That path isn’t picked outright. As an alternative, it emerges from a number of small steering impulses blended collectively, arguing politely till one wins.
Every replace follows the identical sample: first we determine on a desired path, then we constrain how briskly that path is allowed to vary, and at last we flip the consequence into a brief Bézier phase. The remainder of this part walks via how that path is chosen.
Steering the path
The movement is pushed by a force-based mannequin impressed by Craig Reynolds’ Steering Behaviors (for background studying, see this hyperlink and this hyperlink). Moderately than simulating a number of brokers, we deal with the snake as a single entity that blends a number of behavioral impulses into one path vector.
The forces we mix are search, orbit, coil, wandering, and turn-rate limiting. Individually they’re easy, however collectively they produce movement that feels intentional somewhat than mechanical.
Search pulls the snake towards the goal when it’s distant. A small buffer helps keep away from fast switching as we strategy the orbit zone.
if (dist > orbitRadius * 1.5) {
desiredDir = targetDir
}
Orbit takes over when the snake will get shut. As an alternative of charging straight in, we compute a tangent path across the goal (a 90° rotation on the bottom airplane), which makes the snake circle at a roughly fixed distance.
const tangent = new Vector3(-targetDir.z, 0, targetDir.x) // perpendicular on XZ airplane
const radiusError = dist - orbitRadius
const radialStrength = radiusError * 0.1
desiredDir = coilTangent.clone().addScaledVector(targetDir, radialStrength).normalize()
Coil provides a vertical part whereas orbiting, producing an up-and-down movement. The peak follows a sine wave, and the path follows its spinoff, which retains the motion easy somewhat than jerky.
const coilY = coilAmplitude * coilFrequency * Math.cos(coilFrequency * orbitAngle) * coilActivation
const coilTangent = new Vector3(tangent.x, coilY, tangent.z)
desiredDir = coilTangent.clone().addScaledVector(targetDir, radialStrength).normalize()
Wander introduces refined variation so the movement doesn’t look mechanically good. Actual animals don’t transfer in completely straight strains. In the event that they did, they’d look suspicious.
Two unbiased noise samples rotate the path horizontally and vertically. Moderately than changing the path outright, we mix the ensuing delta in at a low weight:
const wander = wanderForce(lastDir, noise2D, noiseTime, wanderStrength, tiltStrength)
const wanderDelta = wander.clone().sub(lastDir)
desiredDir.add(wanderDelta.multiplyScalar(wanderWeight))
Lastly, we apply a turn-rate limiter. It doesn’t matter what the forces determine, the snake can solely rotate by maxTurnRate per phase. This constraint is what retains the movement easy and prevents the snake from immediately altering its thoughts and folding again on itself.
operate limitTurnRate(present: Vector3, desired: Vector3, maxRate: quantity): Vector3 {
const angle = present.angleTo(desired)
if (angle <= maxRate) return desired.clone() // small change, use as-is
if (angle < 0.001) return present.clone() // near-identical, skip
const axis = new Vector3().crossVectors(present, desired)
// ... deal with degenerate parallel/anti-parallel case ...
axis.normalize()
return present.clone().applyAxisAngle(axis, maxRate)
}
const newDir = limitTurnRate(lastDir, desiredDir, maxTurnRate)
From path to curve
Now now we have a path. Time to make a curve. Every replace produces a brief cubic Bézier phase.
The endpoint is just the earlier level superior alongside the brand new path (no surprises right here):
// endpoint
const endPoint = lastPoint.clone().add(newDir.clone().multiplyScalar(size))
The management factors determine how a lot the curve is allowed to bend. Their distance scales with how sharply the path modified: quick handles for straight movement, longer ones for tighter turns (as a result of sharp corners are not often look):
// management factors
const turnAngle = lastDir.angleTo(newDir)
const turnFactor = Math.min(1, turnAngle / (Math.PI / 2))
const controlDist = size * (0.33 + 0.34 * turnFactor)
const cp1 = lastPoint.clone().add(lastDir.clone().multiplyScalar(controlDist))
const cp2 = endPoint.clone().sub(newDir.clone().multiplyScalar(controlDist))
const curve = new CubicBezierCurve3(lastPoint, cp1, cp2, endPoint)
This adaptive vary was chosen empirically. It retains the curve easy with out overshooting. After a couple of failed experiments, it seems restraint helps.
To maintain segments becoming a member of cleanly,
cp1follows the earlier path andcp2follows the brand new one. This preserves G1 continuity, which means every phase leaves in precisely the path the earlier one arrived.
Producing particular person segments solves native smoothness, however the snake’s physique strikes repeatedly. To handle that, EndlessCurve treats a number of segments as a single, steady path.
3. Infinite curve
We’d like a system that treats a number of Bézier segments as a single steady path by producing new segments forward, eradicating outdated ones behind, and retaining the snake’s orientation easy and twist-free alongside the way in which.
The sliding window
The snake’s physique is only a window shifting alongside an ever-growing path. Each body, it advances ahead by a small quantity:
this.distance += delta * this.config.pace
this.curve.configureStartEnd(this.distance, this.config.size)
configureStartEnd() handles the bookkeeping. It ensures there’s sufficient curve forward, removes something that’s fallen behind the tail, and updates a neighborhood [0, 1] parameter area representing the seen portion of the snake:
configureStartEnd(place: quantity, size: quantity): void {
this.fillLength(place + size) // generate forward
this.removeCurvesBefore(place) // trim behind
const localPos = this.localDistance(place)
const totalLen = this.getLengthSafe()
this.uStart = totalLen > 0 ? localPos / totalLen : 0
this.uLength = totalLen > 0 ? size / totalLen : 1
}
Because the window strikes ahead, new curve segments are generated solely when wanted. Every phase is brief (4–8 items), so in follow this normally means including one or two curves at a time, simply sufficient to remain forward of the movement. Whereas different segments that fall fully behind the tail are eliminated.
Despite the fact that outdated curves are eliminated, the worldwide distance counter retains rising. distanceOffset bridges the 2, permitting the remainder of the system to proceed working in international coordinates with no need to know that cleanup is occurring.
As soon as the sliding window is configured, the trail is remapped into a neighborhood [0, 1] area, the place u=0 represents the tail and u=1 represents the top.
getPointAtLocal(u: quantity): Vector3 {
return this.getPointAt(this.uStart + this.uLength * u)
}
Extracting regular
Along with the curve itself, we want a secure reference body. Selecting the improper one results in seen twisting artifacts.
To position geometry on the tube floor, the vertex shader constructs an orthonormal TBN body at every level alongside the backbone. The tangent is easy; the traditional is the place issues are likely to go improper. A poor alternative causes the cross-section to rotate unpredictably, making the physique seem twisted.
A typical strategy is to undertaking a set world-up vector:
N = normalize(up - T * dot(up, T))
This works whereas the curve stays largely horizontal. When the tangent aligns with the up vector, nonetheless, the projection collapses and the body turns into unstable.
The answer is parallel transport. As an alternative of computing frames independently, the traditional is propagated ahead alongside the curve by making use of the identical rotation that aligns the earlier tangent with the brand new one. This produces a secure body with minimal twist.
Parallel Transport Frames
personal parallelTransport(
prevNormal: Vector3,
prevTangent: Vector3,
newTangent: Vector3
): Vector3 {
const dot = prevTangent.dot(newTangent)
if (dot > 0.9999) return prevNormal.clone()
const axis = new Vector3().crossVectors(prevTangent, newTangent).normalize()
const angle = Math.acos(Math.max(-1, Math.min(1, dot)))
const rotated = prevNormal.clone().applyAxisAngle(axis, angle)
rotated.sub(newTangent.clone().multiplyScalar(rotated.dot(newTangent)))
return rotated.normalize()
}
This produces minimal twist: the body rotates solely as a lot because the curve itself requires. No further spin is launched except the trail bodily calls for it, reminiscent of in a full helical flip.
Body caching
Parallel transport is sequential: every body depends upon the earlier one. To make random entry environment friendly, frames are precomputed and cached at fastened samples alongside every Bézier phase.
When a body is requested at an arbitrary parameter worth, the code finds the encompassing cached samples and interpolates between them:
return cache.normals[low]
.clone()
.lerp(cache.normals[high], t)
.normalize()
As a result of the samples are shut collectively, linear interpolation is enough and avoids the price of spherical interpolation.
Cache upkeep
When outdated curve segments are eliminated, their cached frames are discarded as nicely and the remaining parameter values are recomputed. For the small variety of lively segments concerned, the price is negligible, and the body knowledge stays tightly bounded in reminiscence.
With a steady curve and secure orientation knowledge in place, we are able to transfer on to rendering. The aim now’s to show this mathematical backbone right into a convincing three-dimensional physique.
4. Producing the snake
Sadly, snakes are usually not strains. They’re three-dimensional our bodies with thickness, a tapering tail, a heavier head, refined twists, and floor element that actually needs to catch the sunshine.
This part focuses on turning that curve into one thing that truly seems to be like a snake, with out rebuilding geometry each body. We’ll cowl instancing, and rendering strategies to make it extra real looking.
Constructing the scales
This undertaking takes a special strategy from the same old: the snake’s physique is assembled from instanced geometry, with all positioning dealt with within the vertex shader. The CPU samples the curve and uploads the consequence as textures. The GPU does the remainder.
Every occasion is a straightforward OctahedronGeometry. When flattened and barely overlapped, these octahedrons type a tile-like floor that reads as scales.
Constructing knowledge textures
Two DataTexture objects carry the curve knowledge to the GPU. The 2 textures serve totally different roles:
- Regular texture: the RGB channels retailer the floor regular, encoded as
worth * 0.5 + 0.5to suit the[-1, 1]vary into[0, 1]. The shader decodes it again withworth * 2.0 - 1.0.
- Place texture: the RGB channels retailer the world-space XYZ place of every backbone pattern.
The updateTextures() operate is run each body to get place + regular:
for (let i = 0; i < texturePoints; i++) {
const u = i / (texturePoints - 1)
const foundation = this.curve.getBasisAtLocal(u)
const idx = i * 4
posData[idx] = foundation.place.x
posData[idx + 1] = foundation.place.y
posData[idx + 2] = foundation.place.z
posData[idx + 3] = 1.0
// Encode normals as 0-1 vary
normData[idx] = foundation.regular.x * 0.5 + 0.5
normData[idx + 1] = foundation.regular.y * 0.5 + 0.5
normData[idx + 2] = foundation.regular.z * 0.5 + 0.5
normData[idx + 3] = 1.0
}
Realism
As soon as the physique exists, the remaining work is about convincing the attention. Small anatomical cues (thickness modifications, asymmetry, floor element, and lighting) do many of the heavy lifting.
A number of small results contribute to the ultimate look: a non-uniform radius profile alongside the backbone, an elliptical cross-section, a refined twist to interrupt symmetry, and floor shading that emphasizes grazing angles and elongated highlights. Individually these are easy, however collectively they push the physique away from a easy tube and towards one thing that reads as natural.
Radius
Actual snakes aren’t uniform cylinders, so the radius varies alongside the backbone:
spineU: 0.0 --------- 0.74 ---------- 0.85 ----- 0.95 ---- 1.0
tail tip physique neck head tip
[ramp up] [full thick] [pinch] [bulge] [close]
Elliptical
Snakes are wider than they’re tall. The cross part is modeled as a flattened ellipse somewhat than a circle, utilizing a 0.5:0.8 vertical-to-horizontal ratio.
float radiusNormal = scale * u_radiusN; // vertical
float radiusBinormal = scale * u_radiusB; // horizontal
Flat Stomach
A small stomach offset (u_zOffset = 0.2) pushes the underside outward barely, making a refined ventral floor — a kind of particulars that’s barely noticeable till it’s lacking.
ringOffset += spineNormal * combinedThickness * u_zOffset;
Twist
A delicate twist is utilized alongside the backbone. This breaks up visible regularity and prevents the floor from studying as a superbly aligned grid. Actual animals not often cooperate with good symmetry.
float twistedTheta = theta + spineU * u_twistAmount;
Regular
A refined noise-based perturbation provides micro-scale variation to the floor normals. This breaks up overly clear lighting and helps the physique catch mild inconsistently, as scales do in actuality.
Stomach Lighting
Most snakes have a lighter stomach. The shader identifies the underside utilizing cos(vTheta) and applies a multiplicative brightness increase. As a result of the adjustment is multiplicative somewhat than additive, the spot sample stays seen (simply lighter), which matches how pigmentation works on actual snakes.
Lighting
A number of lighting tweaks reinforce the phantasm of a rounded, shiny physique:
- Rim lighting (energy ≈ 3.5) accentuates grazing angles, making the silhouette learn clearly in opposition to darker backgrounds
- Shadow compression (
diffuse * 0.6 + 0.4) prevents the shaded aspect from collapsing into pure black, loosely approximating subsurface scattering - Anisotropic specular highlights stretch reflections alongside the physique, mimicking the directional microstructure of actual snake scales
5. What’s subsequent
Stomach-constrained movement
Limit motion so the snake all the time travels on its ventral aspect, stopping unrealistic rolling and reinforcing grounded movement.
Stricter coiling habits
Refine the orbit and coil forces to provide tighter, extra deliberate wrapping, particularly throughout shut interplay with the goal.
A extra anatomical head
Introduce a definite head mesh with unbiased shaping, jaw definition, and eye placement, whereas retaining it pushed by the identical curve and body system.
Environmental interplay
Lengthen the steering system to answer obstacles, surfaces, or terrain, permitting the snake to navigate areas somewhat than open floor.
Different our bodies
Swap the rendering layer to create tentacles, ropes, vines, or summary trails — the curve system stays unchanged.
6. Conclusion
This undertaking explores how steady movement can emerge from easy guidelines, with out counting on predefined paths or heavyweight geometry. By producing the curve incrementally, managing it as a sliding window, and pushing many of the rendering work onto the GPU, the snake feels fluid and responsive whereas remaining environment friendly and predictable.
The actual win isn’t the snake itself, however the construction round it: movement, continuity, and rendering are all dealt with independently, which retains the system versatile because it grows.
The broader takeaway is the sample itself. By decoupling movement, path administration, and rendering, advanced habits can emerge from easy elements. This strategy scales nicely past snakes to any system that wants steady, responsive movement with out predefined paths.
What We Constructed
- An actual-time curve system that generates easy, steady paths from noisy enter
- A memory-bounded, infinite path constructed from chained Bézier segments
- Steady, twist-free orientation utilizing parallel transport frames
- A GPU-driven rendering pipeline utilizing instanced geometry and knowledge textures
- A procedural snake physique with scale-like construction, anatomical shaping, and bodily motivated lighting


