Custom Code Experiment •
Liquid Crystal
Source Code View HTML / CSS / JavaScript
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Liquid crystal</title>
<style>
:root{
--bg:#e7e5ef;
--field:#fbfaff;
--halo:.20;
color-scheme:light;
box-sizing:border-box;
padding-top:env(safe-area-inset-top,0px);
padding-bottom:env(safe-area-inset-bottom,0px);
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--bg:#0a0818;
--field:#1b1638;
--halo:.28;
color-scheme:dark;
}
}
:root[data-theme="dark"]{
--bg:#0a0818;
--field:#1b1638;
--halo:.28;
color-scheme:dark;
}
*,*::before,*::after{box-sizing:inherit}
html{height:100%;scroll-padding-top:env(safe-area-inset-top,0px)}
body{
margin:0;
height:100%;
overflow:auto;
background-color:var(--bg);
background-image:radial-gradient(circle at 50% 50%, var(--field) 0, var(--bg) min(70vmin, 620px));
display:grid;
place-items:center;
}
.stage{
display:grid;
place-items:center;
padding:24px;
}
.orb{
position:relative;
width:min(84vmin, 560px);
aspect-ratio:1 / 1;
}
.orb canvas{
position:absolute;
inset:0;
width:100%;
height:100%;
display:block;
}
.halo{
position:absolute;
inset:20%;
border-radius:50%;
background:conic-gradient(from 0deg, #c3459e, #f2d64b, #35c9b5, #3a6ff0, #8a3cc8, #c3459e);
filter:blur(44px);
opacity:calc(var(--halo) * (0.55 + var(--melt, 1) * 0.6));
animation:spin 16s linear infinite;
}
.blob{
display:none;
position:absolute;
inset:26%;
border-radius:42% 58% 55% 45% / 48% 42% 58% 52%;
background:conic-gradient(from 200deg, #8a3cc8, #3a6ff0, #35c9b5, #f2d64b, #f07a6b, #c3459e, #8a3cc8);
animation:morph 5s ease-in-out infinite alternate, spin 12s linear infinite;
}
.no-gl canvas{display:none}
.no-gl .blob{display:block}
@keyframes spin{to{transform:rotate(360deg)}}
@keyframes morph{
0%{border-radius:42% 58% 55% 45% / 48% 42% 58% 52%}
100%{border-radius:58% 42% 40% 60% / 40% 60% 44% 56%}
}
@media (prefers-reduced-motion: reduce){
.halo{animation:none}
.blob{animation:none}
}
</style>
</head>
<body>
<main class="stage">
<div class="orb" id="orb" role="img" aria-label="A glassy, iridescent liquid that gathers into a faceted crystal, then melts again">
<div class="halo" aria-hidden="true"></div>
<canvas id="lc" aria-hidden="true"></canvas>
<div class="blob" aria-hidden="true"></div>
</div>
</main>
<script id="vs" type="x-shader/x-vertex">
attribute vec2 aPos;
void main(){ gl_Position = vec4(aPos, 0.0, 1.0); }
</script>
<script id="fs" type="x-shader/x-fragment">
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
uniform vec2 uRes;
uniform float uTime;
uniform float uOrder; // 0 = liquid, 1 = crystal
uniform vec2 uTilt;
uniform float uLight;
uniform float uSweep;
uniform float uGlint;
mat2 gTy; mat2 gTx; mat2 gSpin; mat2 gWob;
float gMelt; float gDropOn;
vec3 gD0; vec3 gD1; vec3 gD2; vec3 gD3; vec4 gDR;
const mat2 R45 = mat2(0.7071068, -0.7071068, 0.7071068, 0.7071068);
mat2 rot(float a){ float c = cos(a), s = sin(a); return mat2(c, -s, s, c); }
float smin(float a, float b, float k){
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
float sdBox(vec3 p, vec3 b){
vec3 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}
vec3 tiltP(vec3 p){ p.yz = gTy * p.yz; p.xz = gTx * p.xz; return p; }
vec3 spinP(vec3 p){ p.xz = gSpin * p.xz; p.xy = gWob * p.xy; return p; }
// Faceted gem: octahedron with an octagonal girdle
float crystal(vec3 p){
vec3 q = p; q.y *= 0.8;
float o = (abs(q.x) + abs(q.y) + abs(q.z) - 0.66) * 0.57735;
float b1 = sdBox(q, vec3(0.45, 4.0, 0.45));
vec2 r = R45 * q.xz;
float b2 = sdBox(vec3(r.x, q.y, r.y), vec3(0.40, 4.0, 0.40));
return max(o, max(b1, b2));
}
// Rippling liquid core
float liquid(vec3 p){
float t = uTime;
float w = sin(p.x * 4.0 + t * 1.7) * sin(p.y * 3.5 - t * 1.3) * sin(p.z * 4.0 + t * 1.1);
w += 0.6 * sin(p.x * 7.0 - t * 2.3 + p.y * 2.0) * sin(p.z * 6.5 + t * 1.9 - p.y * 1.5);
return length(p * vec3(1.0, 0.92, 1.0)) - 0.56 - 0.055 * w;
}
float map(vec3 pw){
vec3 q = tiltP(pw);
vec3 p = spinP(q);
float d = crystal(p);
if (gMelt > 0.002) d = mix(d, liquid(p), gMelt);
if (gDropOn > 0.5){
d = smin(d, length(q - gD0) - gDR.x, 0.26);
d = smin(d, length(q - gD1) - gDR.y, 0.26);
d = smin(d, length(q - gD2) - gDR.z, 0.26);
d = smin(d, length(q - gD3) - gDR.w, 0.26);
}
return d;
}
vec3 dropPos(float fi, float orbit){
float a = uTime * (0.75 + 0.18 * fi) + fi * 1.5708;
float R = orbit * (0.88 + 0.12 * sin(uTime * 1.1 + fi * 2.1));
return vec3(cos(a) * R, sin(a * 1.3 + fi * 1.7) * 0.30 * orbit, sin(a) * R);
}
float dropRad(float fi, float base){ return base * (0.75 + 0.3 * sin(uTime * 1.9 + fi * 2.7)); }
vec3 calcN(vec3 p){
const float e = 0.0015;
vec2 k = vec2(1.0, -1.0);
return normalize(k.xyy * map(p + k.xyy * e) + k.yyx * map(p + k.yyx * e) +
k.yxy * map(p + k.yxy * e) + k.xxx * map(p + k.xxx * e));
}
float lobe(float a, float c, float w){ float x = (a - c) * w; return exp(-x * x); }
// Procedural studio: softbox, strip lights, coloured rims
vec3 env(vec3 d){
float y = d.y;
float a = atan(d.z, d.x);
vec3 lo = mix(vec3(0.012, 0.010, 0.030), vec3(0.50, 0.50, 0.58), uLight);
vec3 hi = mix(vec3(0.080, 0.070, 0.150), vec3(0.95, 0.95, 0.99), uLight);
vec3 col = mix(lo, hi, smoothstep(-1.0, 1.0, y));
col += vec3(1.5) * smoothstep(0.62, 0.93, y);
float band = smoothstep(-0.35, 0.2, y) * (1.0 - smoothstep(0.5, 0.8, y));
col += vec3(1.5, 1.45, 1.38) * lobe(a, 0.7, 6.0) * band;
col += vec3(1.05, 1.15, 1.4) * lobe(a, -2.3, 6.0) * band;
float hz = 1.0 - smoothstep(0.0, 0.5, abs(y));
col += vec3(1.0, 0.22, 0.62) * lobe(a, -0.8, 2.4) * hz;
col += vec3(0.12, 0.72, 1.0) * lobe(a, 2.4, 2.4) * hz;
col += vec3(0.4, 1.0, 0.55) * lobe(a, -1.7, 3.0) * hz * 0.35;
return col;
}
// Thin-film interference on the surface
vec3 thinFilm(float cosT, float d){
float n = 1.38;
float ct = sqrt(max(0.0, 1.0 - (1.0 - cosT * cosT) / (n * n)));
float opd = 2.0 * n * d * ct;
return 0.5 + 0.5 * cos(6.2831853 * opd / vec3(640.0, 545.0, 460.0));
}
// Birefringence colours (Michel-Lévy) for a retardation G in nm
float ml1(float G, float l){ float s = sin(3.14159265 * G / l); return s * s; }
vec3 michelLevy(float G){
vec3 c = vec3(0.35, 0.0, 1.0) * ml1(G, 430.0)
+ vec3(0.0, 0.3, 1.0) * ml1(G, 470.0)
+ vec3(0.0, 0.9, 0.6) * ml1(G, 510.0)
+ vec3(0.3, 1.0, 0.1) * ml1(G, 550.0)
+ vec3(1.0, 0.75, 0.0) * ml1(G, 590.0)
+ vec3(1.0, 0.25, 0.0) * ml1(G, 630.0)
+ vec3(0.8, 0.0, 0.05) * ml1(G, 670.0);
return c / vec3(3.45, 3.2, 2.75);
}
vec3 march(vec3 ro, vec3 rd, float t0, float t1){
float t = t0, md = 1e4, tm = t0;
bool exited = false;
for (int i = 0; i < 110; i++){
float d = map(ro + rd * t);
if (d < md){ md = d; tm = t; }
if (d < 0.0007) return vec3(t, 0.0, t);
t += d * 0.68;
if (t > t1){ exited = true; break; }
}
// ran out of steps while still skimming the surface: count it as a hit
if (!exited && md < 0.02) return vec3(tm, 0.0, tm);
return vec3(-1.0, md, tm);
}
float traceIn(vec3 ro, vec3 rd){
float t = 0.0;
for (int i = 0; i < 40; i++){
float d = -map(ro + rd * t);
if (d < 0.002) break;
t += max(d * 0.85, 0.008);
if (t > 2.6) break;
}
return t;
}
vec3 shade(vec3 p, vec3 rd, vec2 uv){
vec3 n = calcN(p);
vec3 po = spinP(tiltP(p));
float cosT = clamp(dot(-rd, n), 0.0, 1.0);
float F = 0.05 + 0.95 * pow(1.0 - cosT, 4.0);
float thick = 380.0 + 170.0 * sin(po.y * 4.2 + uTime * 0.7)
+ 110.0 * sin(dot(po, vec3(3.3, 1.9, 2.6)) - uTime * 1.2);
vec3 film = thinFilm(cosT, thick);
vec3 R = reflect(rd, n);
vec3 refl = env(R) * (0.35 + 1.3 * film);
vec3 ri = refract(rd, n, 0.6667);
if (dot(ri, ri) < 1e-4) ri = rd;
vec3 pin = p - n * 0.012;
float ti = traceIn(pin, ri);
vec3 pe = pin + ri * ti;
vec3 ne = calcN(pe);
vec3 tir = reflect(ri, -ne);
vec3 oR = refract(ri, -ne, 1.47); if (dot(oR, oR) < 1e-4) oR = tir;
vec3 oG = refract(ri, -ne, 1.50); if (dot(oG, oG) < 1e-4) oG = tir;
vec3 oB = refract(ri, -ne, 1.54); if (dot(oB, oB) < 1e-4) oB = tir;
vec3 tr = vec3(env(oR).r, env(oG).g, env(oB).b);
float G = ti * 1050.0
+ 230.0 * sin(dot(po, vec3(2.4, 3.2, 1.4)) * 1.3 + uTime * 0.9)
+ 150.0 * sin(po.y * 6.5 - po.x * 2.0 - uTime * 1.4);
vec3 ml = michelLevy(max(G, 0.0));
tr = tr * (0.4 + 0.9 * ml) + ml * mix(0.42, 0.5, uLight);
vec3 col = mix(tr, refl, F);
float spec = pow(max(dot(R, normalize(vec3(-0.45, 0.85, 0.55))), 0.0), 90.0);
col += spec * (1.4 + film);
col += pow(1.0 - cosT, 3.0) * film * 0.2;
float sx = (uv.x * 0.8 + uv.y - uSweep) * 7.0;
col += exp(-sx * sx) * uGlint * (0.5 + 0.7 * film);
return col;
}
void main(){
vec2 uv = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y;
gTy = rot(uTilt.y);
gTx = rot(uTilt.x);
gSpin = rot(uTime * 0.5);
gWob = rot(0.16 * sin(uTime * 0.37));
float m = clamp(1.0 - uOrder, 0.0, 1.0);
gMelt = m * m * (3.0 - 2.0 * m);
float orbit = mix(0.95, 0.0, smoothstep(0.05, 0.8, uOrder));
float dr = 0.13 * (1.0 - smoothstep(0.55, 0.92, uOrder));
gDropOn = dr > 0.002 ? 1.0 : 0.0;
gD0 = dropPos(0.0, orbit); gD1 = dropPos(1.0, orbit);
gD2 = dropPos(2.0, orbit); gD3 = dropPos(3.0, orbit);
gDR = vec4(dropRad(0.0, dr), dropRad(1.0, dr), dropRad(2.0, dr), dropRad(3.0, dr));
vec3 ro = vec3(0.0, 0.0, 4.0);
vec3 rd = normalize(vec3(uv, -1.65));
float b = dot(ro, rd);
float c = dot(ro, ro) - 1.69;
float h = b * b - c;
if (h < 0.0){ gl_FragColor = vec4(0.0); return; }
h = sqrt(h);
vec3 res = march(ro, rd, max(-b - h, 0.0), -b + h);
vec3 col;
float alpha = 1.0;
if (res.x > 0.0){
col = shade(ro + rd * res.x, rd, uv);
} else {
float w = res.z / (uRes.y * 1.65) * 1.6;
if (res.y > w){ gl_FragColor = vec4(0.0); return; }
alpha = 1.0 - smoothstep(0.0, w, res.y);
col = shade(ro + rd * res.z, rd, uv);
}
col *= mix(1.15, 1.4, uLight);
col = 1.0 - exp(-col);
gl_FragColor = vec4(col * alpha, alpha);
}
</script>
<script>
(() => {
const root = document.documentElement;
const orb = document.getElementById('orb');
const canvas = document.getElementById('lc');
const reduceMQ = matchMedia('(prefers-reduced-motion: reduce)');
const darkMQ = matchMedia('(prefers-color-scheme: dark)');
const isLight = () => {
const t = root.getAttribute('data-theme');
if (t === 'light') return true;
if (t === 'dark') return false;
return !darkMQ.matches;
};
// ---------- Animation cycle (seconds): liquid → crystallise → crystal → melt ----------
const LIQUID = 1.8, FORM = 3.2, SOLID = 2.6, MELT = 1.8;
const PERIOD = LIQUID + FORM + SOLID + MELT;
const FORMED = LIQUID + FORM;
const ease = x => x * x * (3 - 2 * x);
function orderAt(c) {
if (c < LIQUID) return 0;
c -= LIQUID;
if (c < FORM) return ease(c / FORM);
c -= FORM;
if (c < SOLID) return 1;
c -= SOLID;
return 1 - ease(Math.min(c / MELT, 1));
}
// ---------- WebGL setup ----------
let gl = null, prog = null, U = {};
try {
gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: true, antialias: false, powerPreference: 'high-performance' });
} catch (e) { gl = null; }
function compile(type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(s));
return null;
}
return s;
}
function initGL() {
if (!gl) return false;
const vs = compile(gl.VERTEX_SHADER, document.getElementById('vs').textContent);
const fs = compile(gl.FRAGMENT_SHADER, document.getElementById('fs').textContent);
if (!vs || !fs) return false;
prog = gl.createProgram();
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(prog));
return false;
}
gl.useProgram(prog);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, 'aPos');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
['uRes', 'uTime', 'uOrder', 'uTilt', 'uLight', 'uSweep', 'uGlint'].forEach(n => U[n] = gl.getUniformLocation(prog, n));
gl.clearColor(0, 0, 0, 0);
return true;
}
let glOK = initGL();
if (!glOK) orb.classList.add('no-gl');
canvas.addEventListener('webglcontextlost', e => {
e.preventDefault();
glOK = false;
orb.classList.add('no-gl');
});
// ---------- Sizing with adaptive quality ----------
let scale = Math.min(window.devicePixelRatio || 1, 1.75);
function resize() {
const r = canvas.getBoundingClientRect();
const w = Math.max(1, Math.round(r.width * scale));
const h = Math.max(1, Math.round(r.height * scale));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
}
if ('ResizeObserver' in window) new ResizeObserver(resize).observe(canvas);
window.addEventListener('resize', resize);
resize();
// ---------- Pointer tilt ----------
const tilt = { x: 0, y: 0 }, tiltT = { x: 0, y: 0 };
window.addEventListener('pointermove', e => {
tiltT.x = (e.clientX / window.innerWidth - 0.5) * 0.9;
tiltT.y = (e.clientY / window.innerHeight - 0.5) * 0.5;
});
document.addEventListener('pointerleave', () => { tiltT.x = 0; tiltT.y = 0; });
// ---------- Loop ----------
let last = performance.now();
let shaderTime = 0, cycle = 0, light = isLight() ? 1 : 0;
let glintAt = -1;
let statSum = 0, statN = 0, warm = 0;
function frame(now) {
const rawDt = (now - last) / 1000;
const dt = Math.min(rawDt, 0.1);
last = now;
const reduce = reduceMQ.matches;
const speed = reduce ? 0.3 : 1;
shaderTime += dt * speed;
const prev = cycle;
cycle = (cycle + dt * speed) % PERIOD;
if (prev < FORMED && cycle >= FORMED && !reduce) {
glintAt = now;
const target = glOK ? canvas : orb.querySelector('.blob');
if (target.animate) {
target.animate(
[{ transform: 'scale(1)' }, { transform: 'scale(1.04)', offset: 0.35 }, { transform: 'scale(1)' }],
{ duration: 800, easing: 'ease-out' }
);
}
}
const order = orderAt(cycle);
light += ((isLight() ? 1 : 0) - light) * (1 - Math.exp(-dt * 6));
tilt.x += (tiltT.x - tilt.x) * (1 - Math.exp(-dt * 3));
tilt.y += (tiltT.y - tilt.y) * (1 - Math.exp(-dt * 3));
let sweep = 0, glint = 0;
if (glintAt >= 0) {
const g = (now - glintAt) / 900;
if (g <= 1) { sweep = -1.4 + 2.8 * g; glint = Math.sin(Math.PI * g); }
else glintAt = -1;
}
orb.style.setProperty('--melt', (1 - order).toFixed(3));
if (glOK) {
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform2f(U.uRes, canvas.width, canvas.height);
gl.uniform1f(U.uTime, shaderTime);
gl.uniform1f(U.uOrder, order);
gl.uniform2f(U.uTilt, tilt.x, 0.24 + tilt.y);
gl.uniform1f(U.uLight, light);
gl.uniform1f(U.uSweep, sweep);
gl.uniform1f(U.uGlint, glint);
gl.drawArrays(gl.TRIANGLES, 0, 3);
// drop resolution on slower GPUs (skip the first second and tab-switch spikes)
warm += dt;
if (warm > 1 && rawDt < 0.09) {
statSum += rawDt; statN++;
if (statN >= 45) {
if (statSum / statN > 0.026 && scale > 0.6) {
scale = Math.max(0.6, scale * 0.82);
resize();
}
statSum = 0; statN = 0;
}
}
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>