Line Integral Convolution for Flow-Field Art
Render one deterministic LIC composition and validate its streamlines, kernel symmetry, boundary policy, and exported texture hash.
Line integral convolution combs seeded noise along a vector field until direction becomes texture. Build the fibers as both a visual composition and an inspectable numerical edition.
Line integral convolution turns direction into texture
Line integral convolution filters a noise texture along streamlines of a vector field, making local direction visible as continuous fibers. For generative art, the method offers an unusually legible bridge between equation, integration, kernel, and surface: each aesthetic choice can remain in the export receipt.
Cabral and Leedom’s original LIC paper describes one-dimensional curvilinear filtering through vector fields. This tutorial uses that idea with a synthetic analytic field and a fixed-step browser implementation.
The artifact is not computational fluid dynamics, scientific validation, or a performance benchmark. It produces art from a declared equation, seeded noise, bounded samples, and browser pixels, then exposes clipping and invalid-sample counts rather than hiding them.
LIC begins with a dense noise texture because each output pixel needs local variation to smear along the flow. Seed that texture and expose it beside the result; otherwise a changed random source can masquerade as an integrator, kernel, or color-mapping improvement.
Test canonical fields before trusting a complex composition: constant horizontal flow should create horizontal correlation, a rotational field should follow concentric direction away from its singularity, and mirrored fixtures should preserve their expected symmetry within tolerance. Compare a few pixel accumulations with a slow reference implementation. These tests catch sign, direction, normalization, and boundary errors that a beautiful vortex can conceal.
Author a field with safe singularities
Define v(x,y) in normalized coordinates and decide what magnitude means. Directional LIC commonly normalizes nonzero vectors so tracing speed does not brighten high-magnitude regions, while magnitude can be preserved in a separate palette or diagnostic layer.
Line integral convolution must specify behavior near zero vectors. Stop the streamline, count the condition, or use a bounded fallback direction; dividing by a tiny magnitude creates explosive steps and nonfinite pixels that can appear as accidental visual drama.
The sample combines a rotational field with sinusoidal perturbations and normalizes it. To preserve authored gestures, begin with flow-field calligraphy and translate its strokes into a continuous field with explicit boundary behavior.
The vector field supplies direction, while normalization deliberately discards magnitude for the streamline step in this teaching edition. Near zero vectors, normalization becomes unstable, so count and visualize invalid samples rather than replacing them with a plausible-looking arbitrary direction in the final texture.
- Field noise and LIC triptych
- Identical coordinates show the actual normalized vector field, xorshift32 noise, and the final directional texture.
| Panel | Executed definition |
|---|---|
| Field | normalize(−y+p, x+p), p=.18 sin(πx) sin(πy) |
| Noise | xorshift32 seed 731; 160×110 Float32 samples |
| LIC | symmetric 16-step, .75-pixel, half-Hann convolution |
Generate noise from a pinned seed
Use a documented pseudo-random generator, integer seed, dimensions, and value range. Seeded noise gives the convolution repeatable local material; changing generator, color management, or resolution can change the pixel hash even when the field equation stays fixed.
Line integral convolution relies on high-frequency input so nearby streamlines remain distinguishable. White noise is a useful baseline, but art direction can introduce blue-noise distributions, paper grain, masks, or multiscale texture when their generator and influence are recorded separately.
The lab’s actual xorshift32 recurrence fills one Float32 noise array before rendering. That same array drives bilinear forward/backward traces, pixel output, noise checkpoints, and exported hashes; no second generator quietly supplies the diagnostics. It never loads a copyrighted source image, and the receipt identifies the generated fixture rather than implying firsthand observation of a natural flow.
LIC flow visualization integrates forward and backward from every pixel. Symmetric stepping reduces directional bias, boundary checks stop the trace, and a fixed maximum prevents pathological fields from monopolizing a frame; each stop reason belongs in the diagnostic receipt. The receipt also records accepted samples before normalization and kernel weighting.
Runnable artifact — The composition uses a synthetic vector field, fixed-step teaching integrator, and browser raster output; it is not a scientific CFD result or performance benchmark.
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Seeded line integral convolution studio</title><style>:root{color-scheme:dark}*{box-sizing:border-box}body{font:16px/1.45 system-ui;background:#091318;color:#f4f7f6;max-width:980px;margin:auto;padding:24px}main{display:grid;gap:16px;min-width:0}fieldset,.panel{border:1px solid #8aa0aa;border-radius:12px;padding:14px}fieldset{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}label{display:grid;gap:5px}button,input,select,a,textarea{font:inherit;padding:9px}button,a{min-height:44px}textarea{width:100%;min-height:150px;background:#071014;color:#f4f7f6}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.status{padding:10px;border-left:5px solid #46e0c1;background:#10242b}svg,canvas{max-width:100%;height:auto}.sr{position:absolute;left:-9999px}table{border-collapse:collapse;width:100%;table-layout:fixed}th,td{padding:7px;border:1px solid #8aa0aa;text-align:left;overflow-wrap:anywhere}@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important;scroll-behavior:auto!important}}canvas{width:100%;image-rendering:auto;border:1px solid #8aa0aa;background:#071014}</style><main><h1>Seeded line integral convolution studio</h1><p>One xorshift32 noise field feeds the canvas, the hashes, and every forward/backward streamline receipt.</p><fieldset><legend>Current deterministic edition</legend><label>Seed<input id="seed" type="number" value="731"></label><label>Steps each direction<input id="steps" type="number" min="2" max="32" value="16"></label><label>Step length<input id="stepLength" type="number" min="0.1" max="2" step="0.05" value="0.75"></label></fieldset><p><button id="run">Render current field</button> <a id="pngExport" download="lic-current.png">Export current PNG</a> <a id="jsonExport" download="lic-current.json">Export current JSON</a></p><canvas id="canvas" width="160" height="110" aria-label="Current deterministic line integral convolution"></canvas><p id="status" class="status" aria-live="polite"></p><textarea id="receipt" readonly aria-label="Execution receipt"></textarea></main><script>const W=160,H=110,canvas=document.getElementById('canvas'),context=canvas.getContext('2d'),receipt=document.getElementById('receipt'),statusNode=document.getElementById('status');
function xorshift32(seedValue){let state=(seedValue>>>0)||0x9e3779b9;return()=>{state^=state<<13;state^=state>>>17;state^=state<<5;return(state>>>0)/4294967296}}
function createNoise(seedValue){const random=xorshift32(seedValue),values=new Float32Array(W*H);for(let index=0;index<values.length;index++)values[index]=random();return values}
function field(nx,ny){const perturb=.18*Math.sin(Math.PI*nx)*Math.sin(Math.PI*ny),vx=-ny+perturb,vy=nx+perturb,magnitude=Math.hypot(vx,vy);return magnitude<1e-12?{vx:0,vy:0,magnitude:0,zero:true}:{vx:vx/magnitude,vy:vy/magnitude,magnitude,zero:false}}
function bilinear(values,x,y,counters){const x0=Math.floor(x),y0=Math.floor(y),x1=Math.min(W-1,x0+1),y1=Math.min(H-1,y0+1),tx=x-x0,ty=y-y0;if(x0<0||y0<0||x0>=W||y0>=H)throw Error('bilinear sample outside field');counters.interpolationSamples++;const top=values[y0*W+x0]*(1-tx)+values[y0*W+x1]*tx,bottom=values[y1*W+x0]*(1-tx)+values[y1*W+x1]*tx;return top*(1-ty)+bottom*ty}
function trace(values,startX,startY,direction,options,counters,fieldFunction=field,keepPath=false){let x=startX,y=startY,weighted=0,weightTotal=0,arcLength=0,clipped=false,zeroVector=false;const path=keepPath?[[x,y]]:null;for(let index=1;index<=options.steps;index++){const nx=x/(W-1)*2-1,ny=y/(H-1)*2-1,vector=fieldFunction(nx,ny);if(!Number.isFinite(vector.vx+vector.vy)){counters.invalidSamples++;break}if(vector.zero){counters.zeroVectors++;zeroVector=true;break}const nextX=x+direction*vector.vx*options.stepLength,nextY=y+direction*vector.vy*options.stepLength;if(nextX<0||nextX>W-1||nextY<0||nextY>H-1){counters.clips++;clipped=true;break}const distance=Math.hypot(nextX-x,nextY-y),weight=.5+.5*Math.cos(Math.PI*index/(options.steps+1));x=nextX;y=nextY;arcLength+=distance;weighted+=bilinear(values,x,y,counters)*weight;weightTotal+=weight;if(path)path.push([x,y])}return{weighted,weightTotal,arcLength,clipped,zeroVector,path}}
function convolve(noise,x,y,options,counters){const center=bilinear(noise,x,y,counters),forward=trace(noise,x,y,1,options,counters),backward=trace(noise,x,y,-1,options,counters),weightTotal=1+forward.weightTotal+backward.weightTotal;return{value:(center+forward.weighted+backward.weighted)/weightTotal,forward,backward,weightTotal}}
const shaBuffer=async buffer=>[...new Uint8Array(await crypto.subtle.digest('SHA-256',buffer))].map(value=>value.toString(16).padStart(2,'0')).join('');
async function execute(){try{const seedValue=Number(seed.value)>>>0,options={steps:Number(steps.value),stepLength:Number(stepLength.value)};if(!Number.isInteger(options.steps)||options.steps<2||options.steps>32||!Number.isFinite(options.stepLength)||options.stepLength<=0)throw Error('invalid integration controls');const noise=createNoise(seedValue),fieldSamples=new Float32Array(W*H*2);let fieldMaxNormError=0,fieldIndex=0;for(let y=0;y<H;y++)for(let x=0;x<W;x++){const vector=field(x/(W-1)*2-1,y/(H-1)*2-1);fieldSamples[fieldIndex++]=vector.vx;fieldSamples[fieldIndex++]=vector.vy;if(!vector.zero)fieldMaxNormError=Math.max(fieldMaxNormError,Math.abs(Math.hypot(vector.vx,vector.vy)-1))}const counters={clips:0,zeroVectors:0,invalidSamples:0,interpolationSamples:0},gray=new Float32Array(W*H);let forwardArcSum=0,backwardArcSum=0;for(let y=0;y<H;y++)for(let x=0;x<W;x++){const result=convolve(noise,x,y,options,counters);gray[y*W+x]=result.value;forwardArcSum+=result.forward.arcLength;backwardArcSum+=result.backward.arcLength}const pixels=new Uint8ClampedArray(W*H*4);let min=Infinity,max=-Infinity,sum=0;for(let index=0;index<gray.length;index++){const value=gray[index];min=Math.min(min,value);max=Math.max(max,value);sum+=value;const byte=Math.round(value*255),offset=index*4;pixels[offset]=Math.round(byte*.42);pixels[offset+1]=Math.round(byte*.76);pixels[offset+2]=byte;pixels[offset+3]=255}context.putImageData(new ImageData(pixels,W,H),0,0);const centerX=(W-1)/2,centerY=(H-1)/2,probeX=centerX+8,probeY=centerY,probeCounters={clips:0,zeroVectors:0,invalidSamples:0,interpolationSamples:0},probePixel=convolve(noise,probeX,probeY,options,probeCounters),forwardProbe=trace(noise,probeX,probeY,1,options,probeCounters,field,true),backwardProbe=trace(noise,probeX,probeY,-1,options,probeCounters,field,true),constant=(nx,ny)=>({vx:1,vy:0,magnitude:1,zero:false}),zeroField=(nx,ny)=>({vx:0,vy:0,magnitude:0,zero:true}),fixtureCounters={clips:0,zeroVectors:0,invalidSamples:0,interpolationSamples:0},symForward=trace(noise,centerX,centerY,1,{steps:4,stepLength:1},fixtureCounters,constant,true),symBackward=trace(noise,centerX,centerY,-1,{steps:4,stepLength:1},fixtureCounters,constant,true),zeroProbe=trace(noise,centerX,centerY,1,options,fixtureCounters,zeroField,true),blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/png')),data={algorithm:{rng:'xorshift32',interpolation:'bilinear',field:'normalized perturbed circular field',kernel:'symmetric Hann half-kernel',normalization:'divide weighted noise sum by actual accumulated weight'},seed:seedValue,dimensions:[W,H],options,noiseCheckpoint:[...noise.slice(0,8)],diagnostics:{...counters,fieldMaxNormError,forwardArcSum,backwardArcSum,arcDirectionDelta:Math.abs(forwardArcSum-backwardArcSum),min,max,mean:sum/gray.length},probe:{coordinate:[probeX,probeY],accumulatedValue:probePixel.value,totalWeight:probePixel.weightTotal,forward:{arcLength:forwardProbe.arcLength,weightTotal:forwardProbe.weightTotal,points:forwardProbe.path},backward:{arcLength:backwardProbe.arcLength,weightTotal:backwardProbe.weightTotal,points:backwardProbe.path}},fixtures:{constantFieldSymmetry:{forwardArc:symForward.arcLength,backwardArc:symBackward.arcLength,delta:Math.abs(symForward.arcLength-symBackward.arcLength)},zeroVector:{stopped:zeroProbe.zeroVector,arcLength:zeroProbe.arcLength}},hashes:{noise:await shaBuffer(noise.buffer),field:await shaBuffer(fieldSamples.buffer),grayscale:await shaBuffer(gray.buffer),pixels:await shaBuffer(pixels.buffer),png:await shaBuffer(await blob.arrayBuffer())}};const invariants={finite:Object.values(data.diagnostics).every(Number.isFinite),noInvalidSamples:counters.invalidSamples===0,normalizedField:fieldMaxNormError<1e-6,symmetricFixture:data.fixtures.constantFieldSymmetry.delta<1e-12,zeroFixtureStops:data.fixtures.zeroVector.stopped&&data.fixtures.zeroVector.arcLength===0,probeTwoWay:data.probe.forward.arcLength>0&&data.probe.backward.arcLength>0&&Math.abs(data.probe.totalWeight-(1+data.probe.forward.weightTotal+data.probe.backward.weightTotal))<1e-12,sharedNoise:data.hashes.noise===await shaBuffer(noise.buffer)};if(!Object.values(invariants).every(Boolean))throw Error('LIC execution invariant failed');data.invariants=invariants;window.__licLast={noise,fieldSamples,gray,pixels,probePixel,forwardProbe,backwardProbe,data};pngExport.href=URL.createObjectURL(blob);jsonExport.href=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'}));receipt.dataset.execution=JSON.stringify(data);receipt.value='PASS: '+JSON.stringify(data,null,2);statusNode.textContent='Rendered seed '+seedValue+' · '+counters.interpolationSamples+' bilinear samples · '+counters.clips+' boundary stops'}catch(error){receipt.dataset.execution=JSON.stringify({unexpectedError:error.name+': '+error.message});receipt.value='FAIL: unexpected '+error.message}}
run.onclick=()=>void execute();window.__lic={xorshift32,createNoise,field,bilinear,trace,convolve,execute};void execute();</script></html>
Trace symmetrically through the pixel
Start at the pixel center and integrate forward and backward along the normalized vector. Use the same step size, maximum step count, interpolation rule, and boundary policy in both directions; including only downstream samples creates a directional bias unrelated to the symmetric texture being taught.
The fast, resolution-independent LIC paper develops acceleration and controlled integration beyond the compact educational loop here. Cite those distinctions instead of describing a small Canvas implementation as state of the art.
Line integral convolution quality depends on integrator error in curved regions. Compare fixed-step Euler with a higher-order or adaptive reference on selected fields before using the art tool for scientific interpretation.
A vector field texture is not a particle trail. Every output sample averages source noise along a local streamline, producing dense directional coherence even where no single long path is visible; kernel choice determines how strongly nearby positions contribute to that fiber and its edge softness.
Choose a kernel as an artistic instrument
A box kernel weights accepted samples equally and yields direct streaks, while a Hann window softens endpoints and can reduce abrupt texture changes. Normalize by the sum of weights actually sampled, including the center, so boundary clipping does not darken the frame by losing mass.
Expose kernel, support length, step size, interpolation, and normalization in controls. The same field can become smoky, etched, or silky as those settings change, but the exported label should describe the mechanism rather than a physical material claim.
The line integral convolution lab offers box and Hann paths. Its test verifies symmetric positive Hann weights; browser diagnostics carry the values generated by the executing run instead of relying on decorative figure numbers.
Streamline convolution with a box kernel is easy to explain but can show abrupt window structure. A Hann kernel tapers the ends, changing softness and frequency response; the composition offers both as named artistic controls rather than claiming one is scientifically superior.
Interpolate and clip deliberately
Nearest-neighbor noise sampling is easy to inspect but can alias. Bilinear interpolation improves continuity, provided coordinates, edge extension, and sample-center convention are documented and tested at corners, half pixels, and out-of-bounds positions.
Count every boundary termination, zero vector, and invalid interpolation. High clip counts near a vortex edge may be expected, yet they change support and should appear in a diagnostic view rather than being erased from a polished image.
Line integral convolution can also wrap, mirror, clamp, or mask boundaries for art. Each choice changes topology: periodic wrap makes a tile, mirror creates reflected flow, and early stop creates a visibly finite domain.
Integration error becomes visible around tight curvature and boundaries. Overlay clipped steps, near-zero cells, and high-turn samples on demand, then use those layers to tune step length and count before touching the palette; decoration should follow numerical legibility, a recorded parameter change, and a fresh diagnostic export.
- Streamline kernel microscope
- The executed probe exports forward and backward paths, arc length, half-Hann weights, bilinear samples, and one normalized pixel.
| Direction | Steps | Arc | Weight sum |
|---|---|---|---|
| Backward | 16 | 12 px | 8 |
| Center | 1 | 0 | 1 |
| Forward | 16 | 12 px | 8 |
Separate direction from magnitude
LIC texture primarily reveals orientation; normalized tracing suppresses speed. Add magnitude as line density, luminance, hue, or a companion map only after defining perceptual scaling and an accessible semantic legend, because color can otherwise imply quantitative precision the field never had.
For motion-derived work, optical-flow typography provides a different provenance: the vector field comes from estimated video motion and inherits its errors. Do not combine that label with a synthetic equation without exposing the source transition.
The article figures distinguish the analytic vector panel, seeded noise, convolved output, and diagnostics. Their layout is a visual thesis; the browser JSON remains the evidence for a particular rendered receipt.
Flow-field generative art can map analytic parameters to recognizable visual gestures: curl sets rotation, shear bends fibers, and source positions create tension. Keep the formula beside the controls so the aesthetic vocabulary remains connected to reproducible field quantities.
Resolution changes the relationship between field scale, step length, kernel support, and visible fiber width. Define parameters in normalized or pixel units deliberately, and decide which should remain invariant when exporting a poster-size edition. Simply enlarging the canvas with fixed pixel steps produces a different convolution, while resampling a small output can blur away the field’s directional structure.
Export pixels and parameters together
Use the Canvas 2D standard for ImageData and PNG export, then hash the pixel buffer in a declared channel order. Preserve dimensions, device-independent sample grid, seed, field formula, integrator, kernel, diagnostic counts, browser, and color assumptions with the image.
Line integral convolution output hashes can vary if browser raster or color paths change. Hashing the raw RGBA array before PNG encoding narrows that uncertainty, while the PNG hash still identifies the downloadable artifact.
Never print a short illustrative hash in editorial art as if it were the current run. The lab generates its full SHA-256 at execution, and the figure labels the location of that field rather than inventing a value.
Line integral convolution art gains edition integrity from the seed, canvas dimensions, field parameters, kernel, step length, sample count, color map, and exported pixel hash. The hash identifies one supported raster path; it does not imply cross-browser color-management identity.
Art-direct without erasing the ledger
Crop, palette, composite, and layer the texture after the base LIC receipt is saved. If post-processing changes pixels, produce a derived artifact with its own parameters and hash instead of overwriting the source record; that lineage lets an artist return to the directional foundation.
Dense fibers can be contrasted with Hough-transform line art or converted into plotter-safe flow paths. These methods extract or construct different geometry, so a creative comparison should show their distinct failures rather than ranking them by one generic sharpness score.
Human review remains responsible for rhythm, hierarchy, visual fatigue, and intentional focal areas. Diagnostics protect craft by making accidental artifacts easier to distinguish from authored tension.
The browser artifact is a fixed-step visual instrument, not CFD. It makes no claim about conservation, turbulence, measured flow, or solver convergence, and its diagnostics describe this synthetic field and rasterizer rather than validating a physical system.
Accessibility can extend beyond the semantic parameter table. Provide a plain-language description of the dominant flow, focal region, and contrast, and avoid rapid motion when animating kernels or fields. A still PNG plus receipt should remain available under reduced motion. If magnitude is encoded by color, add numeric ranges or texture channels so the artwork’s explanatory layer does not rely on hue alone.
Ship a reproducible flow-field plate
Archive equation source, coordinate system, dimensions, seed and generator, integration directions, step, count, kernel, interpolation, edge policy, normalization, diagnostic maps, pixel hash, PNG hash, browser, and visual review notes. Include rights and provenance for every added texture.
Run the lab twice with one seed and confirm raw pixel hashes match on the supported path, then change kernel and verify the receipt and image both change. Inspect zero-vector and edge-heavy fixtures before trusting a calm central composition.
Revisit line integral convolution when the integrator, kernel, interpolation, Canvas color management, or art direction changes. The enduring creative principle is measured freedom: equations may be playful while their rendering choices remain inspectable.
Review the edition when integration, interpolation, kernel, Canvas color handling, or art direction changes. The noise-to-fiber transformation can lead a newsletter spread, while the downloadable parameters and diagnostic layers preserve the analytical construction behind the finished image and its reproducible seed.
- Flow-texture diagnostic map
- The default committed render reports boundary stops, zero vectors, invalid samples, interpolation count, and a deterministic pixel hash.
- Boundary stops
- 2,749 streamline directions
- Zero vectors / invalid samples
- 0 / 0
- Bilinear samples
- 554,525
- Default parameters
- seed 731; 16 steps per direction; .75-pixel step
- Pixel receipt
- SHA-256 4c4c9020449452efb0326dfc66a37dc4927e18801ee1e068675fc82cff2375a8
- The current-input render supplies both this diagnostic ledger and the exported pixels.