Pass 1 renders eight plasma sheets into an atlas with linear depth in alpha; pass 2 marches the refracted ray to each sheet and absorbs it by real path length, so near and far layers separate under pointer parallax. Plasma cycle repeats exactly every 30 s; - / = change speed.
113 lines
14 KiB
HTML
113 lines
14 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Сквозь лёд</title>
|
||
<style>
|
||
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#03111d}
|
||
canvas{display:block;width:100%;height:100%;touch-action:none}
|
||
#info{position:fixed;bottom:20px;left:24px;color:#d8f4ff;font:13px/1.6 system-ui,sans-serif;text-shadow:0 1px 5px #001;pointer-events:none}
|
||
#info strong{font-weight:500} #info span{opacity:.75}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<canvas id="view" aria-label="Плазма под прозрачным льдом. Двигайте указатель для параллакса."></canvas>
|
||
<div id="info" role="status"><strong>Сквозь лёд</strong><br><span id="help">Двигайте мышь · скорость − / =</span></div>
|
||
<script>
|
||
'use strict';
|
||
const sceneSource = "// Pass 1: eight plasma sheets, one atlas tile each. Alpha stores linear depth.\n// Density field lit as a height surface: billowing volumes with a real terminator.\nprecision highp float;\nuniform vec2 u_res; // size of one tile\nuniform float u_time;\n\nfloat hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }\nfloat noise(vec2 p) {\n vec2 i = floor(p), f = fract(p);\n f = f*f*(3.0-2.0*f);\n return mix(mix(hash(i), hash(i+vec2(1.0,0.0)), f.x),\n mix(hash(i+vec2(0.0,1.0)), hash(i+vec2(1.0,1.0)), f.x), f.y);\n}\nfloat fbm(vec2 p) {\n float sum = 0.0, amp = 0.5;\n for (int i = 0; i < 4; i++) {\n sum += amp * noise(p);\n p = mat2(1.62, 1.17, -1.17, 1.62) * p;\n amp *= 0.5;\n }\n return sum;\n}\nfloat fbm2(vec2 p) {\n return 0.5 * noise(p) + 0.25 * noise(mat2(1.62, 1.17, -1.17, 1.62) * p);\n}\nconst float EXTENT = 3.8; // half-width of a sheet in world units; must match the ice pass\n\nvoid main() {\n vec2 tile = floor(gl_FragCoord.xy / u_res);\n float layer = tile.x + tile.y * 4.0;\n vec2 uv = fract(gl_FragCoord.xy / u_res);\n vec2 p = (uv - 0.5) * 2.0 * vec2(EXTENT * u_res.x / u_res.y, EXTENT);\n p *= 0.85 + 0.10 * layer; // far sheets carry finer detail, which reads as distance\n float angle = hash(vec2(layer, 3.1)) * 6.2831853;\n p = mat2(cos(angle), sin(angle), -sin(angle), cos(angle)) * p;\n\n // Periodic time: every field returns exactly after 30 seconds.\n float phase = u_time * 6.2831853 / 30.0;\n vec2 o = vec2(cos(phase), sin(phase));\n vec2 seed = vec2(layer * 17.3, layer * 9.1);\n\n // Two nested warp stages, the marbled-plasma motion.\n vec2 q = vec2(fbm(p * 0.50 + seed + o * 0.55),\n fbm(p * 0.50 + seed + 23.1 - o * 0.55));\n vec2 r = vec2(fbm(p * 0.85 + 2.0 * q + seed + vec2(o.y, -o.x) * 0.40),\n fbm(p * 0.85 + 2.0 * q + seed + 31.7 - vec2(o.y, -o.x) * 0.40));\n vec2 w = p * 1.10 + 2.6 * r + seed; // warped coords of the density field\n\n // The silhouette is shared by all sheets (slid a little per depth), so the gaps\n // stay open right through the stack instead of filling in with overlap.\n float mass = smoothstep(0.520, 0.548, fbm((p + o * 0.30 + vec2(0.10, -0.07) * layer) * 0.62 + r * 1.1 + 13.7));\n\n // Light the density as a height surface: $cheap 2-octave gradient, strong terminator.\n float h = 0.05;\n vec2 slope = vec2(fbm2(w + vec2(h, 0.0)) - fbm2(w - vec2(h, 0.0)),\n fbm2(w + vec2(0.0, h)) - fbm2(w - vec2(0.0, h))) * (10.0 / (2.0 * h));\n vec3 nrm = normalize(vec3(-slope, 1.0));\n float lit = clamp(dot(nrm, normalize(vec3(-0.38, 0.52, 0.76))), 0.0, 1.0);\n\n // Folded planes across the volume, feathered so they never read as a grid.\n vec2 g = (p + r * 2.0) * 1.4;\n vec2 cell = floor(g), f = fract(g);\n float feather = smoothstep(0.0, 0.10, min(min(f.x, 1.0 - f.x), min(f.y, 1.0 - f.y)));\n float facet = mix(1.0, mix(0.70, 1.25, hash(cell + seed + step(1.0, f.x + f.y) * 37.0)), feather);\n\n float shade = 0.60 + 0.55 * lit; // soft light across the folded planes\n float e = mass * facet * shade * 0.075\n + mass * (1.0 - mass) * 4.0 * 0.015 // thin torn silhouette\n + mass * pow(lit, 14.0) * 0.045; // glint where a fold faces the light\n vec3 col = e * mix(vec3(0.02, 0.42, 0.60), vec3(0.08, 0.64, 0.88), facet * 0.5);\n // Faint dye in the open gaps so darkness reads as depth, not as a hole.\n col += smoothstep(0.40, 0.85, fbm(p * 0.30 + seed * 0.4 + o * 0.3)) * vec3(0.0001, 0.0004, 0.0006);\n\n gl_FragColor = vec4(col, (0.42 + layer * 0.26) / 2.6);\n}\n";
|
||
const iceSource = "// Pass 2: clean ice. Refraction, chromatic shear, absorption by true path length.\n// Nothing is added to empty space, so the gaps between sheets stay black.\nprecision highp float;\nuniform vec2 u_res;\nuniform float u_time;\nuniform vec2 u_mouse; // normalized, bottom-left origin\nuniform sampler2D u_scene;\nuniform vec2 u_tile_res;\n\nconst float EXTENT = 3.8; // must match the plasma pass\nconst float FRONT = -2.0; // ice surface, camera at the origin looking down -z\nconst vec3 SIGMA = vec3(0.66, 0.26, 0.11); // absorption per unit: red dies first, so depth turns blue\nconst float IOR = 1.31;\n\nfloat hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }\nfloat noise(vec2 p) {\n vec2 i = floor(p), f = fract(p);\n f = f*f*(3.0-2.0*f);\n return mix(mix(hash(i), hash(i+vec2(1.0,0.0)), f.x),\n mix(hash(i+vec2(0.0,1.0)), hash(i+vec2(1.0,1.0)), f.x), f.y);\n}\nfloat fbm(vec2 p) {\n float sum = 0.0, amp = 0.5;\n for (int i = 0; i < 3; i++) { sum += amp * noise(p); p = mat2(1.62, 1.17, -1.17, 1.62) * p; amp *= 0.5; }\n return sum;\n}\nvec2 atlasUV(vec2 world, float layer) {\n vec2 uv = world / (2.0 * EXTENT) + 0.5;\n uv = clamp(uv, 0.5 / u_tile_res, 1.0 - 0.5 / u_tile_res); // keep bilinear taps inside the tile\n return (vec2(mod(layer, 4.0), floor(layer / 4.0)) + uv) / vec2(4.0, 2.0);\n}\n// Smooth frozen surface: two octaves, no bubbles, no grain.\nfloat height(vec2 x) {\n return 0.22 * fbm(x * 0.85 + 2.0) + 0.06 * fbm(x * 2.6 - 5.0);\n}\nvoid main() {\n vec2 screen = gl_FragCoord.xy / u_res;\n vec2 p = (gl_FragCoord.xy - 0.5 * u_res) / u_res.y;\n vec2 look = clamp(u_mouse - 0.5, -0.35, 0.35); // orbiting the slab centre\n vec3 ray = normalize(vec3(p - look * 2.2, -1.6));\n\n // Front surface along the view ray; two fixed-point steps settle on the swell.\n float t = FRONT / ray.z;\n for (int i = 0; i < 2; i++) t = (FRONT + height(ray.xy * t)) / ray.z;\n vec3 hit = ray * t;\n\n float e = 0.02;\n vec2 slope = vec2(height(hit.xy + vec2(e, 0.0)) - height(hit.xy - vec2(e, 0.0)),\n height(hit.xy + vec2(0.0, e)) - height(hit.xy - vec2(0.0, e))) / (2.0 * e);\n float bevel = smoothstep(0.14, 0.0, min(min(screen.x, 1.0 - screen.x), min(screen.y, 1.0 - screen.y)));\n slope += bevel * (screen - 0.5) * 0.55; // the slab turns away at the frame edge\n vec3 normal = normalize(vec3(-slope, 1.0));\n float facing = max(dot(-ray, normal), 0.0);\n\n // Refracted ray; red and blue bend a little differently, which is what makes ice ring.\n vec3 inside = refract(ray, normal, 1.0 / IOR);\n vec2 shearR = (refract(ray, normal, 1.0 / (IOR - 0.012)) - inside).xy;\n vec2 shearB = (refract(ray, normal, 1.0 / (IOR + 0.016)) - inside).xy;\n inside = normalize(inside);\n if (inside.z > -0.05) inside = vec3(0.0, 0.0, -1.0);\n\n // Recentre the parallax on the middle of the sheet stack: near and far sheets\n // then slide past each other at different rates as the pointer moves.\n vec2 par = look * 4.54;\n\n vec3 acc = vec3(0.0);\n float trans = 1.0;\n for (int i = 0; i < 8; i++) {\n float layer = float(i);\n float depth = texture2D(u_scene, atlasUV(hit.xy + par, layer)).a * 2.6; // depth buffer from pass 1\n float path = (hit.z - (FRONT - depth)) / -inside.z; // ice crossed to reach this sheet\n vec2 at = (hit + inside * path).xy + par;\n vec3 plasma;\n plasma.r = texture2D(u_scene, atlasUV(at + shearR * path, layer)).r;\n plasma.g = texture2D(u_scene, atlasUV(at, layer)).g;\n plasma.b = texture2D(u_scene, atlasUV(at + shearB * path, layer)).b;\n vec3 c = plasma * exp(-SIGMA * path);\n // Near plumes are nearly opaque, so the sheets behind them show through the gaps\n // instead of averaging into a wash. That is what makes the stack read as depth.\n float alpha = clamp(max(max(c.r, c.g), c.b) * 1.8, 0.0, 0.80);\n acc += trans * c;\n trans *= 1.0 - alpha;\n }\n\n // Gentle shoulder: overlapping glow stays coloured instead of clipping to white.\n vec3 col = vec3(1.0) - exp(-acc * 1.5);\n\n // Glass: reflection and a rim only where the surface turns away. Empty space stays dark.\n float fresnel = pow(1.0 - facing, 5.0);\n vec3 sky = mix(vec3(0.004, 0.016, 0.030), vec3(0.075, 0.220, 0.320), normal.y * 0.5 + 0.5);\n col += sky * (fresnel * 1.2 + bevel * 0.03);\n\n gl_FragColor = vec4(pow(clamp(col, 0.0, 1.0), vec3(0.4545)), 1.0);\n}\n";
|
||
const vertexSource = 'attribute vec2 p; void main(){gl_Position=vec4(p,0.0,1.0);}';
|
||
const canvas = document.getElementById('view'), help = document.getElementById('help');
|
||
const gl = canvas.getContext('webgl', {antialias:false, alpha:false, preserveDrawingBuffer:true});
|
||
function fail(message) { help.textContent = message; throw new Error(message); }
|
||
if (!gl) fail('WebGL недоступен в этом браузере.');
|
||
function program(source) {
|
||
function shader(type, code) {
|
||
const s = gl.createShader(type);
|
||
gl.shaderSource(s, code); gl.compileShader(s);
|
||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) fail('Ошибка шейдера: ' + gl.getShaderInfoLog(s));
|
||
return s;
|
||
}
|
||
const p = gl.createProgram(), vs = shader(gl.VERTEX_SHADER, vertexSource), fs = shader(gl.FRAGMENT_SHADER, source);
|
||
gl.attachShader(p, vs); gl.attachShader(p, fs); gl.linkProgram(p);
|
||
gl.deleteShader(vs); gl.deleteShader(fs);
|
||
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) fail('Ошибка программы: ' + gl.getProgramInfoLog(p));
|
||
return p;
|
||
}
|
||
const scene = program(sceneSource), ice = program(iceSource);
|
||
const buffer = gl.createBuffer();
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,1,1]), gl.STATIC_DRAW);
|
||
let texture, framebuffer, tileWidth, tileHeight;
|
||
function resize() {
|
||
const max = gl.getParameter(gl.MAX_TEXTURE_SIZE);
|
||
const dpr = Math.min(devicePixelRatio || 1, 1.25, max / (innerWidth * 3), max / (innerHeight * 1.5));
|
||
canvas.width = Math.max(4, Math.floor(innerWidth * dpr / 4) * 4);
|
||
canvas.height = Math.max(4, Math.floor(innerHeight * dpr / 4) * 4);
|
||
tileWidth = canvas.width * 3 / 4; tileHeight = canvas.height * 3 / 4;
|
||
if (texture) gl.deleteTexture(texture);
|
||
if (framebuffer) gl.deleteFramebuffer(framebuffer);
|
||
texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture);
|
||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, tileWidth * 4, tileHeight * 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||
framebuffer = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
|
||
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) fail('Не удалось создать текстуру сцены.');
|
||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||
}
|
||
addEventListener('resize', resize); resize();
|
||
const shot = location.hash.startsWith('#shot');
|
||
const shotParams = new URLSearchParams(location.hash.slice(1));
|
||
let mouseX = Number(shotParams.get('mx') ?? 0.5), mouseY = Number(shotParams.get('my') ?? 0.5);
|
||
let speed = 1, time = Number(shotParams.get('t') ?? 0), previous;
|
||
addEventListener('pointermove', e => { mouseX = e.clientX / innerWidth; mouseY = 1 - e.clientY / innerHeight; });
|
||
addEventListener('keydown', e => {
|
||
if (e.key === '-' || e.key === '_') speed = Math.max(0.125, speed / 2);
|
||
else if (e.key === '=' || e.key === '+') speed = Math.min(4, speed * 2);
|
||
else return;
|
||
help.textContent = 'Двигайте мышь · скорость ' + speed + '× (− / =)';
|
||
});
|
||
function draw(p, width, height) {
|
||
gl.useProgram(p);
|
||
const position = gl.getAttribLocation(p, 'p');
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.enableVertexAttribArray(position);
|
||
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
|
||
gl.uniform2f(gl.getUniformLocation(p, 'u_res'), width, height);
|
||
gl.uniform1f(gl.getUniformLocation(p, 'u_time'), time);
|
||
gl.uniform2f(gl.getUniformLocation(p, 'u_mouse'), mouseX, mouseY);
|
||
gl.uniform2f(gl.getUniformLocation(p, 'u_tile_res'), tileWidth, tileHeight);
|
||
gl.uniform1i(gl.getUniformLocation(p, 'u_scene'), 0);
|
||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||
}
|
||
function frame(now) {
|
||
if (previous !== undefined) time += (now - previous) * 0.001 * speed;
|
||
previous = now;
|
||
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
||
gl.viewport(0, 0, tileWidth * 4, tileHeight * 2);
|
||
draw(scene, tileWidth, tileHeight);
|
||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texture);
|
||
draw(ice, canvas.width, canvas.height);
|
||
if (shot) {
|
||
// Headless Firefox does not composite WebGL; expose the actual rendered pixels.
|
||
const pixels = new Uint8Array(canvas.width * canvas.height * 4);
|
||
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||
const output = document.createElement('canvas');
|
||
output.width = canvas.width; output.height = canvas.height;
|
||
const ctx = output.getContext('2d'), image = ctx.createImageData(output.width, output.height);
|
||
for (let y = 0; y < output.height; y++)
|
||
image.data.set(pixels.subarray((output.height - y - 1) * output.width * 4, (output.height - y) * output.width * 4), y * output.width * 4);
|
||
ctx.putImageData(image, 0, 0); canvas.replaceWith(output);
|
||
} else requestAnimationFrame(frame);
|
||
}
|
||
if (shot) frame(0); else requestAnimationFrame(frame);
|
||
</script>
|
||
</body>
|
||
</html>
|