A reference standard for real-time jiggle physics by xlovecam — how to paint soft regions, drive damped spring bones, and deform meshes consistently across engines.
Not ragdoll. Not cloth. Not full soft-body FEM.
Paint weight on a UV map, drive randomized spring bones from parent motion,
one rule:vertex += weight * boneJiggle.
Author: xlovecam
No build step. Open index.html in any WebGL-capable browser.
Spring bones · jiggle bones · soft-body secondary motion · weight-painted physics · vertex weight jiggle · Blender-style flesh bounce · mesh wobble · damped spring deformation · parent-velocity lag · orbit-driven bounce
Two pieces, one contract:
| Piece | What it is |
|---|---|
| Weight map | Per-region softness in [0, 1], painted on a UV texture (one weight per vertex in a real engine). |
| Jiggle bones | A small set of damped springs; each painted region follows one bone. Seeded frequency/damping so regions wobble out of sync. |
The entire deformation (a texel may follow more than one bone):
vertex += Σ_b weight_b * offset_b;Each bone is a damped oscillator in the parent's accelerating frame:
x'' = -ω² x - 2ζω x' - a_parent + g
ω and ζ are the shared tissue response scaled by the bone's region size
(ω ∝ 1/√size, see below), so a wide painted band and a small dot on the same
material move differently, as they do on a body.
The reference step is the exact closed-form solution of that equation, so
it is stable for any dt and defines the output rather than approximating it.
Two conforming engines agree to floating-point precision.
In this demo (SDF ray-marcher), the equivalent samples the base shape at
q - offset, with asymmetric squash & stretch along the motion vector
(trailing bulge, leading flatten). That styling is the renderer's, not the standard's.
The portable asset is weightmap.png + seed. The PNG is lossless RGBA
(RFC 2083): R / G / B are the three bone channels. The seed reproduces the
per-bone character. Together they fully describe a look.
The engine, its extensions, the weight map, and the renderers
are separate by design. jiggle-physics.js does the math only — no DOM, no
WebGL — so you can drop it into any renderer. Extensions never alter the step.
Three demos (1D / 2D / 3D) share the same engine files and link to each other
from a nav at the top of each page.
jiggle/
├── LICENSE # MIT
├── index.html # 3D demo: markup, styles, GLSL shader
├── demo-1d.html # 1D demo: three damping regimes, exact step at any rate
├── demo-2d.html # 2D demo: edge-bone presets per geometry, colliders
├── jiggle-physics.js # core: exact damped-spring step (no DOM, no WebGL)
├── jiggle-colliders.js # extension: plane / sphere / capsule projection
├── jiggle-chain.js # extension: linked bones (tail, hair, antenna)
├── jiggle-weightmap.js # 3D: UV paint, force blur, PNG export/import
├── jiggle-app.js # 3D: WebGL renderer (engine + colliders + weight map)
├── jiggle-demo-1d.js # 1D: Canvas renderer
├── jiggle-demo-2d.js # 2D: Canvas renderer (engine + colliders)
├── jiggle-demo.css # shared chrome for the 1D / 2D pages
└── asset/
└── jiggle-physics-demo.gif
createJigglePhysics({ bones: 3, seed: 1 }) — drop into any game loop.
- No DOM, no WebGL — renderer-agnostic; all WebGL lives in the demo, not the engine.
- Input is the parent acceleration
[ax, ay, az]in the shape's frame — the only external term a spring in an accelerating frame feels. Hosts with only a position usecreateJiggleDriver()(finite difference + smoothing). - Parameters are the observable pair:
freq(Hz) anddamp(damping ratio ζ), plusg(gravity). Absolute mass is not a parameter — it is not observable separately fromk/mandc/m. ζ ≥ 1 (critical / over-damped) is handled. - Relative mass is observable and is per bone:
bones[i].size(default 1) is the mass of the region the bone stands for. Tissuekandcare shared, soωandζboth scale by1/√size— a larger region wobbles slower, rings longer and travels further. The demos derivesizefrom the painted area (Σ weight / texel count, exposed asweights.area[i]), clamped to[0.5, 2]because UV area is only a proxy for surface area. - Per-bone character is seeded (mulberry32): frequency, damping and gravity
multipliers.
reseed()/reseed(n)andshake()use the same RNG — noMath.random()in the engine. - The step is the closed-form damped-oscillator solution; no fixed timestep, no substeps, no accumulator.
- Returns bone offsets each frame:
const physics = createJigglePhysics({ bones: 3, seed: 1 });
const offsets = physics.update(dt, parentAcceleration); // [ax, ay, az]
// Float32Array [x0,y0,z0, x1,y1,z1, ...]Constraint projection after the exact step: each bone is pushed out of any
collider along the surface normal and the velocity component into the surface
is removed (restitution optional). Exact between contacts, a discrete event at
contact — the scheme production spring-bone systems use. limit confines the
offset to a sphere around its rest point (the 3D demo's ray-march guard).
const col = createJiggleColliders({ limit: 0.4, restitution: 0 });
const floor = col.plane(0, 1, 0, -1); // n·p >= d, mutable {nx,ny,nz,d}
const ball = col.sphere(1, 0, 0, 0.5); // keep out, mutable {cx,cy,cz,r}
col.capsule(ax, ay, az, bx, by, bz, r);
col.resolveAll(physics, bx, by, bz); // world = rest point + offsetA bone is a point mass standing in for a region, so this models "region hits a surface". Mesh-vs-mesh self-collision is cloth/FEM territory and stays out.
Link i hangs off link i−1. Each link takes the exact step; its input is the root acceleration plus the acceleration of the link above it this frame. Sequential, exact per link, no coupling forces.
const chain = createJiggleChain({ links: 6, seed: 1 });
const tips = chain.update(dt, rootAccel); // cumulative tip offsetsNo demo page uses the chain; it is a library extension for hosts with tails or hair. Springs between bones would make the system coupled and break the single-oscillator closed form; that is deliberately not part of the standard.
512×256 RGBA paint buffer, Smart-UV projection, force-radius blur, heatmap, and PNG export / import. The demo's host owns sliders and keys; this file owns the texels.
This is one example renderer, not part of the engine. It owns the WebGL setup, orbit camera, controls, and render loop. Each frame it accumulates a virtual parent position from orbit / move / walk, turns it into acceleration with the driver, feeds the engine, clamps the offsets for ray-march safety, and uploads them + the weight textures to the shader.
Five test geometries with demo presets, weight heatmap, physics sliders, and a
2D UV paint window. Offsets are confined to 0.4 and collide with the ground
plane at y = −1; shift-drag the orb into the floor and the painted belly
squashes upward.
Drag a handle; three masses with identical frequency follow at ζ = 0.14 / 1 / 1.7 (under-, critically-, over-damped) with scrolling traces. The step rate slider throttles how often the engine is stepped: the curves keep their shape because the step is exact, only the sample density changes.
Same layout and controls as the 3D page. Cycle disc / capsule / ring (G);
each carries a preset of three bones on its outline, mirroring the orb /
capsule / torus presets. The outline follows them (point += Σ_b w_b · offset_b)
and is tinted by the bone it follows; W shows the bones as rest ring → offset
dot. A floor plane and a draggable ring are colliders; the drive slider
scales how hard dragging accelerates the bones.
Weights live in a 512×256 RGBA texture (R / G / B = three jiggle bones). Each geometry uses a Smart-UV-style projection so paint maps cleanly to the surface:
| Geometry | Projection |
|---|---|
| Orb | Sphere (equirectangular) |
| Capsule | Cylinder along Y |
| Torus | Major ring U + tube V |
| Air dancer | Cylinder along swaying centreline |
| Walker | Cylinder, seam at back |
Paint in the 2D UV map panel (not on the 3D viewport). One texture lookup per sample — no brush cap, no per-step cost scaling.
Export (E) writes jiggle-weights-seed-<seed>.png. Import (I) loads a PNG
into the paint buffer; if the filename contains seed-<n>, the engine reseeds
so the look matches.
| Input | Action |
|---|---|
P |
Paint mode — UV map panel appears |
| Paint in UV window | Add weight (Blender-style flow build-up) |
+ / − |
Add / erase brush |
W |
Toggle weight heatmap (blue = anchored → red = soft) |
G |
Cycle geometry |
C |
Clear weights |
X |
Random paint |
E |
Export weight map PNG |
I |
Import weight map PNG |
Space |
Shake |
R |
Reset camera |
| Drag | Orbit (drives jiggle) |
| Shift-drag | Move object (also drives jiggle) |
| Wheel | Zoom |
Below the UV map — scale painted regions after the fact without re-painting:
- all — master gain on every painted region
- bone 0 / 1 / 2 — per-channel gain (each stroke assigns a bone in rotation)
Each geometry loads a preset weight map on switch (G) that showcases the
standard with multiple out-of-sync bones:
- Orb — heavy sagging bottom + softer equatorial band
- Capsule — heavy hanging bottom cap + mid belly
- Torus — three lobes evenly spaced on the outer rim
- Air dancer — head-heavy stacked rings, feet anchored
- Walker — soft bust, glutes, groin
| Slider | Parameter |
|---|---|
| frequency | natural frequency ω / 2π in Hz |
| damping | damping ratio ζ (1 = critical) |
| gravity | sag under gravity (acceleration) |
| orbit drive | host-side gain: how hard camera orbit drives the parent position |
| brush size | UV paint radius |
| brush weight | paint target strength |
| force radius | jiggle spread from painted regions |
const physics = createJigglePhysics({ bones: 3, seed: 1 });
const driver = createJiggleDriver(); // only if you have position, not acceleration
// each frame: parent acceleration in the shape's frame
const accel = driver.update(dt, [x, y, z]); // or your engine's own value
const offsets = physics.update(dt, accel);
// optional: colliders after the step, before reading offsets
colliders.resolveAll(physics, restX, restY, restZ);
// per vertex in your mesh:
const w = sampleWeights(vertex.uv); // RGB = weight per bone (0..1)
vertex.position += w.r * bone(0) + w.g * bone(1) + w.b * bone(2);The weight map PNG and the seed are the portable assets. The physics engine is the portable simulation. Any renderer that can multiply and add vectors can implement the standard.
open index.html
# or serve statically:
python3 -m http.server 8080BSD 3-Clause — Copyright (c) 2026 xlovecam.
Keep the copyright notice. Do not use the name xlovecam to endorse a derived product without permission. Please also link to github.com/xloveee/jiggle-physics if you ship this standard in a project.
