Educational use only. This write-up and the companion code exist solely for learning: the mathematics and rendering behavior behind generative “silk” strokes, and as a pedagogical reference for coding agents and students. It is not an official product, replacement, or endorsement of the original site. Attribution, the original author, and licensing context are in README.md (“Original Silk and attribution”).
This document describes the generative stroke model implemented in src/silk.ts, aligned with the public Silk / Weave Silk experience (as implemented by codeweive — TypeScript + Vite). It is the reference for why the visuals look the way they do (filaments, glow, symmetry), not a walkthrough of the original minified bundle. §4 documents the vendored Perlin script (public/vendor/noise.js); §5 onward covers the stroke integrator and drawing pipeline.
Each user stroke is a time-evolving open polyline (the curve). Vertices are not fixed to the pointer: they move under damped inertia, Perlin noise forces, optional wind, and spring constraints between neighbors. The same polyline is instanced several times per simulation substep via rotation, optional mirror, and per-instance scale (spiral mode), then stroked on a 2D canvas with low alpha and lighter compositing so overlaps add up to bright “glow.”
There is one logical chain of points; “hair” and ribbons come from physics + many translucent redraws, not from drawing many parallel offset brushes.
Each point (p) stores:
| Field | Role |
|---|---|
x, y |
Current position in symmetry plane coordinates (same space as pointer samples). |
px, py |
Previous position, used as velocity memory for the integrator. |
inputVx, inputVy |
Pointer velocity sample at creation time; decays each substep for “carry” of the stroke direction. |
life |
Integer countdown; when it hits 0 the point is removed from the front of the queue (trail head dies first). |
New samples append at the tail via addPoint(x, y, vx, vy) with life = startLife (default 150).
- Logical canvas space: After HiDPI setup, drawing uses CSS-pixel units on the canvas backing store (context may be scaled by
devicePixelRatio). - Symmetry center ((c_x, c_y)) =
symCenterX,symCenterY(typically the screen center in logical pixels). - Resize / letterbox: If the logical size differs from
originalLogicalWidth/originalLogicalHeight, a uniform scaledrawScale = min(W/W_0, H/H_0, 1)and offsetsoffsetX,offsetYcenter the composition (see constructor insilk.ts).
All physics runs in the unscaled symmetry plane; drawInstruction maps into the scaled, offset screen space used for stroke().
This file is the same Processing-style noise stack the live site loads as js/noise.js: it defines PerlinNoise, the global noise(x, y, z, octaves, fallout) used by src/silk.ts, plus noiseDetail / noiseSeed and noiseProfile on window. Comments in the source point to Ken Perlin’s notes and classic Processing behavior.
A small Marsaglia generator (comment cites a Bielefeld algorithms page) implements nextInt() and nextDouble() with 32-bit-style mixing. PerlinNoise(seed) uses either a user seed or Marsaglia.createRandomized(), which seeds from Date (minute-granularity and sub-minute parts), so reloads usually get a new permutation table unless you fix the seed.
The constructor fills perm[0..255] with 0..255, shuffles with the PRNG (j = rnd.nextInt() & 0xFF swaps), then duplicates into perm[256..511] = perm[0..255] so lookups never need a modulo on the index chain.
For a sample ((x,y,z)), integer lattice cell ((X,Y,Z) = (\lfloor x\rfloor \bmod 256, \ldots)) and fractional parts ((x-X,y-Y,z-Z)). Fade curves use the smoothstep (f(t) = t^2(3-2t)) on each fractional coordinate (same family as improved Perlin).
Eight corners contribute via grad3d(hash, ...): the hash selects one of 16 linear forms in (x,y,z) (an optimized switch version of the usual Perlin gradient selection; comment cites Riven8192). Results are trilinearly interpolated with lerp. noise2d / noise1d exist on the class but are not used by the global noise() in this vendored build.
The exported noise(x, y, z, octaves, fallout) (Silk passes all five arguments) uses a single cached noiseProfile.generator (instantiated as new PerlinNoise(0) at load in this file). For each octave (i = 0 \ldots \texttt{octaves}-1):
effect *= fallouteach iteration (starts at 1, so the first octave’s weight isfallout, not 1 — same as the modified Processing-style loop in the source).k *= 2each octave (frequency doubles).- Accumulate
effect * (1 + noise3d(kx, ky, kz)) / 2, i.e. each raw Perlin sample in roughly ([-1,1]) is mapped to ([0,1]) before weighting.
The return value is a weighted sum of those terms; with large fallout the sum can exceed 1.0 (the file’s docblock warns similarly for Processing).
noiseDetail(octaves, fallout?)stores defaults onnoiseProfilefor callers that read them (Silk does not use this path—it passes octaves/fallout intonoise()directly each call).noiseSeed(seed)assignsnoiseProfile.seedand setsnoiseProfile.generator = undefin the source; in practice the module also pre-assignsgenerator = new PerlinNoise(0), so re-seeding only takes effect if client code reconstructs the generator. This port does not callnoiseSeedfromsilk.ts.
4.6 How src/silk.ts uses it
Silk samples 3D noise at scaled position plus time (see §5.4), then maps the scalar to an angle for acceleration. For that pipeline, only the shape of (N = \texttt{noise}(\ldots)) matters (smooth, multi-frequency); the exact Marsaglia seed affects reproducibility between full page loads, not the character of the motion.
One step advances the curve by one internal tick: increment counters, cull dead head vertices, update every vertex, optionally draw once.
time— incremented every substep; feeds the third coordinate of 3D noise.timeColorScaleTime— incremented every substep; used whenhighlightMode === "time".frameTime— incremented once per outerframe()(used for sparkle cadence).
While the first point has life === 0, shift it off the array. The trail shortens from the start, so the stroke appears to consume itself from the oldest end.
If rotateAnglesAroundSymmetryAxis is true:
[ \theta_{\mathrm{sym}} = \operatorname{atan2}(c_x - y,; c_y - x) ]
(Note the argument order matches the source: atan2(cx - p.y, cy - p.x).)
This rotates the noise and wind directions so forces tend to align with the radial structure around the center.
Silk calls window.noise(sx, sy, sz, octaves, fallout); the implementation and octave rules are in §4. Sample arguments:
[ \begin{aligned} s_x &= \texttt{noiseOffset} + x \cdot \texttt{noiseSpaceScale} + 10^6 \ s_y &= \texttt{noiseOffset} + y \cdot \texttt{noiseSpaceScale} + 10^6 \ s_z &= \texttt{noiseOffset} + \texttt{noiseTimeScale} \cdot t \end{aligned} ]
Let (N \in \mathbb{R}) be the returned value (roughly in a bounded range after octaves). Define:
[ \phi = \texttt{noiseAngleOffset} + \texttt{noiseAngleScale} \cdot N + \theta_{\mathrm{sym}} ]
(with (\theta_{\mathrm{sym}} = 0) if rotation around the symmetry axis is off).
Acceleration contribution:
[ (a_x,\ a_y)_{\mathrm{noise}} = \texttt{noiseForceScale} \cdot (\cos\phi,\ \sin\phi) ]
Defaults: noiseSpaceScale = 0.02, noiseTimeScale = 0.005, noiseOctaves = 8, noiseFallout = 0.65, noiseAngleScale = 5\pi, noiseForceScale = 1.
[ (a_x,\ a_y)_{\mathrm{input}} = \texttt{initialVelocityForceScale} \cdot (\texttt{inputVx},\ \texttt{inputVy}) ]
If both inputVx and inputVy are non-zero, they are multiplied by initialVelocityDecay (default 0.98) each substep so the bias fades.
If windForceScale > 0:
[ \psi = \texttt{windAngle} + \theta_{\mathrm{sym}} ] [ (a_x,\ a_y)_{\mathrm{wind}} = \texttt{windForceScale} \cdot (\cos\psi,\ \sin\psi) ]
Default windForceScale = 0 (off).
Total acceleration ((a_x, a_y)) is the sum of the active terms. Then:
[ \begin{aligned} x &\leftarrow x + (x - p_x)\cdot \mu + a_x \ y &\leftarrow y + (y - p_y)\cdot \mu + a_y \ p_x &\leftarrow x,\quad p_y \leftarrow y \end{aligned} ]
where (\mu = \texttt{friction}) (default 0.975). This is not a full Verlet integrator, but ((x-p_x)) acts like a velocity estimate scaled by friction; adding (a) each step gives smooth, momentum-like motion.
Then life -= 1.
For each index (i \ge 1), let (q) be the previous point (i-1). Vector (\vec{d} = q - p), distance (r = |\vec{d}|). If (r > \texttt{restingDistance} + 0.01):
[ k = \frac{\texttt{rigidity}\cdot(\texttt{restingDistance} - r)}{r} ] [ \vec{f} = k \cdot \vec{d} ]
Then push points apart along (\vec{d}): subtract (\vec{f}) from (p) and add (\vec{f}) to (q) (default restingDistance = 0, rigidity = 0.2). This keeps the chain from collapsing and lets nearby points shear slightly, which reads as fine strands when combined with noise.
Each animation tick:
frameTime += 1- Repeat
drawsPerFrametimes (default 5):step(true).
So per display tick the curve advances 5 substeps and draws 5 times (unless drawThisStep is false). That is why a single gesture builds dense additive color: many semi-transparent strokes per ~16 ms.
Before drawing, generateDrawInstructions() fills an array of instructions. Each instruction is:
cos,sin— combined rotation angle (\alpha = \texttt{rotateBy} + \texttt{spiralAngle} \cdot p_c)rotateBy = rotationIndex * (2\pi / symNumRotations)- (p_c = \texttt{spiralIndex} / \texttt{spiralCopies}) (when
spiralCopies = 1, (p_c = 0))
scale— radial falloff for spirals: a power scale with exponent 0.5, domain ([0,1]), range ([1,0]), timesbrushScaleoriginal— true only for the first rotation/spiral arm (used for sparkle sampling)mirror— ifsymMirror, a second instruction duplicates each arm withmirror: true
Mirror is implemented as negating x after rotation (see below), i.e. reflection across the vertical axis through the symmetry center when the base rotation is 0.
Let ((x,y)) be the frozen simulation positions __x__, __y__ (copies of logical positions before any instance transform).
- Translate to center: (x' = x - c_x,\ y' = y - c_y)
- Rotate and spiral scale (same factor on both axes here):
[ \begin{aligned} x'' &= (x' \cos\alpha - y' \sin\alpha)\cdot s \ y'' &= (x' \sin\alpha + y' \cos\alpha)\cdot s \end{aligned} ]
- If
mirror: (x'' \leftarrow -x'') - Apply
drawScale, re-center, apply offsets:
[ x''' = x'' \cdot \sigma + c_x + \texttt{offsetX},\quad y''' = y'' \cdot \sigma + c_y + \texttt{offsetY} ]
where (\sigma = \texttt{drawScale}).
These ((x''', y''')) are written back into p.x, p.y temporarily for stroking; after all instructions, positions restore from __x__, __y__.
Line width: if scaleLineWidth, ctx.lineWidth = instr.scale (so spiral arms can get thinner).
For each instruction, after the points are transformed:
-
beginPath(),moveTofirst vertex. -
Let (n) be the number of points. With
p1 = curve[1], for integer (i) from 1 to (n - 3) inclusive (i.e.i < lenMinusOne - 1in the source), letp2 = curve[i+1]. The segment uses:- Quadratic Bezier with control point ((p_{1,x}, p_{1,y})) and end point at the midpoint between
p1andp2:
- Quadratic Bezier with control point ((p_{1,x}, p_{1,y})) and end point at the midpoint between
[ \text{end} = \left(\frac{p_{1,x}+p_{2,x}}{2},\ \frac{p_{1,y}+p_{2,y}}{2}\right) ]
stroke()once per instruction (samestrokeStyle/globalAlpha/ composite set bysetColor()).
So the visible curve is a smooth approximation through the moving polyline, not straight segments. With few points, the path is short or empty (the implementation guards length < 2).
Runs at the start of each draw().
- Composite:
globalCompositeOperation = "lighter"for normal strokes (additive RGB clamped per channel by the canvas), or"source-over"in eraser mode when colors match the eraser color. - Alpha:
[ \alpha = \texttt{startOpacity} \cdot \frac{\texttt{life}}{\texttt{startLife}} ]
Default startOpacity = 0.09. The tail vertex (newest) has the highest life, so the leading edge of the stroke is brighter; fading life darkens older parts of the chain.
-
Stroke color: D3 scales map a scalar (t) through HCL interpolation (
interpolateHcl), clamped:velocitymode (default): (t = |\vec{v}_{\mathrm{input}}| = \sqrt{\texttt{inputVx}^2 + \texttt{inputVy}^2}) on the last point; power scale exponent 1.5, domain ([10, 30]), range ([\texttt{color}, \texttt{highlightColor}]).timemode: (t = \texttt{timeColorScaleTime}); linear domain ([0, 350]), range ([\texttt{highlightColor}, \texttt{color}]).
Default hex colors are both #276f9b until the UI changes them.
On non-mirror “original” instructions only, every 10th frameTime, with probability over the curve, a random vertex may spawn a spark: small filled circle on a separate canvas, lighter blend, drifting with random velocity, fading alpha. Color is d3.rgb(strokeStyle).brighter(2) (see src/sparks.ts).
- Clearing / erasing: Any full-canvas fill or
clearRectused to reset pixels must temporarily setglobalCompositeOperationtosource-over"andglobalAlphato 1. Underlighter, filling black does not remove existing light pixels (additive with zero does not subtract). Seesrc/canvas-util.ts. - HiDPI: Backing store may be
width_css × devicePixelRatio; the 2D context is scaled so logical drawing coordinates match CSS pixels. Symmetry center and pointer mapping must stay in that same logical space.
| Parameter | Default | Effect |
|---|---|---|
startLife |
150 | Substeps a vertex survives. |
startOpacity |
0.09 | Base alpha (scaled by life). |
drawsPerFrame |
5 | Physics + draw substeps per tick. |
friction |
0.975 | Damping on ((x-p_x)). |
noiseOctaves / noiseFallout |
8 / 0.65 | Coarse vs fine wobble. |
noiseAngleScale |
(5\pi) | How strongly noise steers direction. |
rigidity |
0.2 | Spring strength between neighbors. |
symNumRotations |
1 | Rotational symmetry order. |
symMirror |
true | Bilateral mirror copy. |
spiralAngle |
(0.75\pi) | Extra twist per spiral copy. |
spiralCopies |
1 (UI may use 30) | Number of spiral arms. |
public/vendor/noise.js— Perlin implementation (see §4).SITE-BREAKDOWN.md— how the live site bundles scripts and classes.PERFORMANCE.md— complexity, canvas cost, and practical limits.src/silk.ts— authoritative implementation for this port.
For attribution and disclaimers, see README.md (“Original Silk and attribution”).