Stretch and stack
Stretch-and-stack flow on lattices: the unit cell warps under the flow and restacks, driven by the shared lattice computation.
Source
common.glsl
// Stretch and Stack — driven by the shared JS lattice driver (script.js).
// uLattice (the lattice mat2) and uFlowInv (inverse of the per-frame flow) are
// pushed from script.js; uScale comes from the UI. UnitCell warps the previous
// frame by uFlowInv to animate the stretch, like the original Shadertoy.
#include "../shared/2d_utils.glsl"
#include "../shared/coord_utils.glsl"
bool fadeMoves = false; // fade the warped trail instead of hard-clearing it
vec4 backCol = vec4(1., 1., 1., 0.);
vec4 dotCol = vec4(.8, .05, .04, .1);
vec4 edgeCol = vec4(.651, .669, .95, .1);
// Screen origin for this view: horizontally centred, one third up from the bottom.
vec2 viewOffset(in vec3 res) { return vec2(res.x * 0.5, res.y / 3.); } fullLattice.glsl
// Buffer for whole plane
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
vec4 col;
vec2 p = coords(fragCoord, iResolution, uScale, viewOffset(iResolution));
mat2 m = mat2(uLattice);
float a = vec4(m).x;
float b = -vec4(m).z;
float c = vec4(m).y;
float d = vec4(m).w;
mat2 im = inverse(m);
vec2 pC = p-m*floor(im*p);
if(a > b)
{
if(pC.x < 0. && pC.y > d) { pC = pC - vec2(-b,d); }
else if(pC.x > 0.)
{
pC = pC + floor(pC.x/b)*vec2(-b, d);
if(pC.y > c )
{
pC = pC - floor((pC.y-c)/d)*vec2(-b, d);
pC = pC - vec2(a,c);
}
}
}
else
{
if(pC.x > 0. && pC.y > c) { pC = pC - vec2(a,c); }
else if(pC.x < 0.)
{
pC = pC + floor(-pC.x/a)*vec2(a, c);
if(pC.y > d )
{
pC = pC - floor((pC.y-d)/c)*vec2(a, c);
pC = pC - vec2(-b,d);
}
}
}
vec2 uv = iCoords(pC, iResolution, uScale, viewOffset(iResolution)) / iResolution.xy;
if(uv.y > .998) { uv.y = .998;}
col = vec4(texture(UnitCell,uv).xyz,1);
// Output to screen
fragColor = col;
} image.glsl
// Fork of "Modular Surface Flows" by Gelada. https://shadertoy.com/view/wsdXRN
// 2019-10-28 18:45:24
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
vec4 col;
vec2 uv = fragCoord.xy / iResolution.xy;
if(uv.y > .998) { uv.y = .998;}
if(uView == 1) { col = vec4(texture(fullLattice,uv).xyz,1); }
else { col = vec4(texture(UnitCell,uv).xyz,1); }
// Output to screen
fragColor = col;
} UnitCell.glsl
// Unit cell — warps the previous frame back by one flow step (uFlowInv) to
// animate the stretch, then draws the current fundamental domain on top.
// Restored from the original Shadertoy, with uFlowInv replacing gFlow(-1.) and
// uLattice replacing the bufA matrix.
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
vec2 p = coords(fragCoord, iResolution, uScale, viewOffset(iResolution));
vec2 oFrag = iCoords(mat2(uFlowInv) * p, iResolution, uScale, viewOffset(iResolution));
vec2 uv = oFrag / iResolution.xy;
vec4 col = backCol;
vec4 pt = uLattice; // (b1.x, b1.y, b2.x, b2.y)
float mixing = 0.;
float ad = 0.0;
if (iFrame == 0 || uNewLattice || uv.x > 1. || uv.x < 0. || uv.y > .99 || uv.y < 0.)
{
col = drBox(col, p, vec4(-pt.x-ad, 0., 0., pt.w+ad), vec4(edgeCol.xyz, 1.), .0);
col = drBox(col, p, vec4(0., 0., -pt.z+ad, pt.y+ad), vec4(dotCol.xyz, 1.), .0);
}
else
{
col = vec4(texture(UnitCell, uv).xyz, 1.);
vec4 fullCol = vec4(texture(fullLattice, uv).xyz, 1.);
if (fadeMoves) {
col = mix(col, vec4(backCol.xyz, 0), .1);
mixing = .1;
} else {
col = vec4(backCol.xyz,0);
mixing = 1.;
}
if (uFade == 1) {
mixing = 1.0;
col = drBox(col, p, vec4(-pt.x-ad, 0., 0., pt.w+ad), vec4(fullCol.xyz, mixing), 0.01);
col = drBox(col, p, vec4(0., 0., -pt.z+ad, pt.y+ad), vec4(fullCol.xyz, mixing), 0.01);
mixing = .01;
col = drBox(col, p, vec4(-pt.x-ad, 0., 0., pt.w+ad), vec4(edgeCol.xyz, mixing), .001);
col = drBox(col, p, vec4(0., 0., -pt.z+ad, pt.y+ad), vec4(dotCol.xyz, mixing), .001);
} else {
col = drBox(col, p, vec4(-pt.x-ad, 0., 0., pt.w+ad), vec4(edgeCol.xyz, mixing), .1);
col = drBox(col, p, vec4(0., 0., -pt.z+ad, pt.y+ad), vec4(dotCol.xyz, mixing), .1);
}
}
fragColor = col;
} lattice/script.js
//import * as math from 'mathjs';
import { matrix, dot, add, subtract, multiply, norm, hypot, subset, index, inv} from 'mathjs';
import * as cfUtils from '../shared/cf_utils.js';
let b1 = [1, 1.618033988749895];
let b2 = [-1.618033988749895, 1];
let p1 = null, p2 = null, hasReturnedFar = false;
let latticeSet = false;
function cDiv(a, b) {
const d = b[0]*b[0] + b[1]*b[1];
return [(a[0]*b[0] + a[1]*b[1]) / d, (a[1]*b[0] - a[0]*b[1]) / d];
}
function hpPt(b1, b2) {
let pt = hypot(...b1) > hypot(...b2) ? cDiv(b1, b2) : cDiv(b2, b1);
if (pt[1] < 0) pt = [-pt[0], -pt[1]];
return pt;
}
function checkCF(flow) {
console.log('checkPeriodSnap called, flow:', flow, 'p1:', p1);
if (p1 === null) { return; }
if (flow !== 0) { return; }
const dist1 = norm(subtract(b1,p1));
const dist2 = norm(subtract(b2,p2));
const dist = Math.sqrt(dist1*dist1+dist2*dist2);
if (dist < .01) {
b1 = [...p1];
b2 = [...p2];
hasReturnedFar = false;
}
}
// Input: basis as [[a, b], [c, d]] (rows are basis vectors)
// Output: reduced basis with b1 = shortest vector
function lagrangeGauss(b1, b2) {
b1 = matrix(b1);
b2 = matrix(b2);
const normSq = v => dot(v, v);
if (normSq(b1) > normSq(b2)) { [b1, b2] = [b2, b1]; }
while (true) {
const m = Math.round(dot(b1, b2) / normSq(b1));
b2 = subtract(b2, multiply(m, b1));
if (normSq(b2) >= normSq(b1)) break;
[b1, b2] = [b2, b1];
}
if(subset(b1,index(1))<0) { b1 = multiply(-1,b1);}
if(subset(b2,index(1))<0) { b2 = multiply(-1,b2);}
return [b1.toArray(), b2.toArray()];
}
// Constrained Gauss–Lagrange reduction.
// Keeps b1 in upper right quadrant
// Keeps b2 in upper left quadrant
function coneReduce(b1, b2) {
// Accept plain arrays or mathjs matrices (onFrame passes multiply results).
b1 = (b1.toArray ? b1.toArray() : b1).slice();
b2 = (b2.toArray ? b2.toArray() : b2).slice();
const clamp = (k, lo, hi) => Math.max(lo, Math.min(hi, k));
let changed = true;
while (changed) {
changed = false;
// shorten b2 along b1, keeping b2 in QII (x <= 0, y >= 0)
{
const q = dot(b1, b1);
if (q > 0) {
let k = Math.round(-dot(b1, b2) / q); // unconstrained minimiser
const hi = b1[0] > 0 ? Math.floor(-b2[0] / b1[0]) : Infinity; // x <= 0
const lo = b1[1] > 0 ? Math.ceil (-b2[1] / b1[1]) : -Infinity; // y >= 0
k = clamp(k, lo, hi);
if (k !== 0) { b2 = add(b2, multiply(k, b1)); changed = true; }
}
}
// shorten b1 along b2, keeping b1 in QI (x >= 0, y >= 0)
{
const q = dot(b2, b2);
if (q > 0) {
let k = Math.round(-dot(b1, b2) / q);
const hi = b2[0] < 0 ? Math.floor(-b1[0] / b2[0]) : Infinity; // x >= 0
const lo = b2[1] > 0 ? Math.ceil (-b1[1] / b2[1]) : -Infinity; // y >= 0
k = clamp(k, lo, hi);
if (k !== 0) { b1 = add(b1, multiply(k, b2)); changed = true; }
}
}
}
return [b1, b2]; // b1/b2 are plain arrays (add/multiply preserve arrays)
}
const fps = 60;
// Geodesic (hyperbolic diagonal) flow matrix — stretches one axis, shrinks the other
function gFlow(dTime) {
return matrix([
[Math.exp(dTime / (3 * fps)), 0],
[0, Math.exp(-dTime / (3 * fps))]
]);
}
// Horocyclic (shear) flow matrix (vertical)
function hFlow(dTime) {
return matrix([
[1, 0],
[dTime / fps, 1]
]);
}
// Horocyclic (shear) flow matrix (horizontal)
function hFlowH(dTime) {
return matrix([
[1, dTime / fps],
[0, 1]
]);
}
export function onSetLattice(inputs, handle) {
const alpha = cfUtils.quadCFStrings(inputs.cfPrefix ?? '', inputs.cfPeriod ?? '0');
const beta = cfUtils.quadCFStrings('', cfUtils.reverseCFString(inputs.cfPeriod) ?? '0');
b1 = [1, beta];
b2 = [-alpha, 1];
[b1,b2] = coneReduce(b1,b2);
//handle.setUniform('uLattice', [1, beta, -alpha, 1]);
latticeSet = true;
}
export function setup(engine) {
b1 = [1, 1.618033988749895];
b2 = [-1.618033988749895, 1];
}
export function onFrame(engine, time, deltaTime, frame) {
const flow = engine.getUniformValue('uFlow');
const speed = engine.getUniformValue('uSpeed');
let m;
switch (flow) {
case 1: m = hFlow(speed); break;
case 2: m = hFlowH(speed); break;
default: m = gFlow(speed * 2.);
}
const mb1 = multiply(m, b1);
const mb2 = multiply(m, b2);
[b1, b2] = coneReduce(mb1, mb2);
checkCF(flow);
// Live CF of the flow's forward endpoint — the future itinerary. This endpoint
// ratio is invariant under the flow (the per-frame scale factors cancel), so it
// stays stable and only advances one CF digit per coneReduce reduction (the
// Gauss map). The invariant ratio depends on flow type/direction:
// geodesic (0): x-ratio forward when speed>=0, else y-ratio (reversed flow)
// horocyclic vertical (1): x-ratio (parabolic fixed endpoint)
// horocyclic horizontal (2): y-ratio
let endNum, endDen;
if (flow === 2 || (flow === 0 && speed < 0)) {
endNum = b2[1]; endDen = b1[1]; // y-ratio
} else {
endNum = b2[0]; endDen = b1[0]; // x-ratio
}
if (Math.abs(endDen) > 1e-9) {
engine.emit('cf', cfUtils.cfExpansion(Math.abs(endNum / endDen), 12).join(', '));
}
// Inverse of this frame's smooth flow (pre-reduction), flattened column-major
// so GLSL `mat2(uFlowInv) * p` walks a point back one flow step. Used by the
// stretch-warp trails; shaders that don't read it simply ignore it.
const mi = inv(m);
engine.setUniformValue('uFlowInv', [mi.get([0, 0]), mi.get([1, 0]), mi.get([0, 1]), mi.get([1, 1])]);
engine.setUniformValue('uLattice', [b1[0], b1[1], b2[0], b2[1]]);
engine.setUniformValue('uNewLattice', latticeSet);
latticeSet = false;
}