Files
shaders/ice_object.html
T
emil28092005 01123b8bac Add ice_object.html: a faceted crystal shard frozen in the slab
Pass 1 draws one raymarched gem on sheet 1 instead of plasma; its alpha
carries coverage rather than depth, so pass 2 can light it as a solid
while every other sheet still absorbs by path length.
2026-10-30 21:30:09 +03:00

113 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 sheets, one atlas tile each; sheet 5 holds a shard frozen in the ice.\n// Alpha stores linear depth, so pass 2 absorbs it like any other sheet.\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\n// Signed distance to an elongated octahedron turning once per cycle. Sharp edges,\n// cheap to march: this is the one thing in the slab that is a real 3D object.\nvec3 shardLocal(vec3 p, float phase) {\n float c1 = cos(phase), s1 = sin(phase);\n p.xz = mat2(c1, -s1, s1, c1) * p.xz;\n float c2 = cos(phase * 2.0 + 1.0), s2 = sin(phase * 2.0 + 1.0);\n p.yz = mat2(c2, -s2, s2, c2) * p.yz;\n p.yz /= vec2(1.0, 1.45);\n return abs(p);\n}\nconst float SHARD = 0.70; // how big the shard is in sheet units\n// Cross two octahedra: the intersection is an eight-sided gem, so the shard still\n// reads as a cut stone from any angle instead of a flat kite.\nvec3 gemAxes(vec3 l, out vec2 r) {\n r = mat2(0.70711, -0.70711, 0.70711, 0.70711) * l.xz;\n return vec3(abs(r.x), l.y, abs(r.y));\n}\nfloat sdShard(vec3 p, float phase) {\n vec3 l = shardLocal(p / SHARD, phase);\n vec2 r;\n vec3 m = gemAxes(l, r);\n float a = (l.x + l.y * 0.86 + l.z - 1.0) * 0.45;\n float b = (m.x * 1.34 + m.y * 0.86 + m.z * 1.34 - 1.0) * 0.40;\n return max(a, b) * SHARD;\n}\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.05 + 1.10 * lit * lit; // lit tops, near-black undersides\n float e = mass * facet * shade * 0.085\n + mass * (1.0 - mass) * 4.0 * 0.040 // thin torn silhouette\n + mass * pow(lit, 14.0) * 0.110; // 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.0003, 0.0011, 0.0015);\n\n float solid = 0.45; // sheet 1 keeps its plasma, but its alpha becomes coverage\n if (layer > 0.5 && layer < 1.5) {\n vec3 ro = vec3(0.0, 0.0, 6.0);\n vec3 rd = normalize(vec3((uv - 0.5) * 2.0 * vec2(u_res.x / u_res.y, 1.0), -1.0));\n float b = dot(ro, rd);\n float disc = b * b - dot(ro, ro) + 2.4 * 2.4;\n if (disc > 0.0) {\n float near = max(-b - sqrt(disc), 0.0);\n float t = near, d = 1.0;\n for (int k = 0; k < 40; k++) {\n d = sdShard(ro + rd * t, phase);\n if (d < 0.003 || t > near + 5.0) break;\n t += d;\n }\n if (d < 0.003) {\n solid = 1.0;\n vec3 pos = ro + rd * t;\n float e2 = 0.004;\n vec3 nrm = normalize(vec3(\n sdShard(pos + vec3(e2, 0.0, 0.0), phase) - sdShard(pos - vec3(e2, 0.0, 0.0), phase),\n sdShard(pos + vec3(0.0, e2, 0.0), phase) - sdShard(pos - vec3(0.0, e2, 0.0), phase),\n sdShard(pos + vec3(0.0, 0.0, e2), phase) - sdShard(pos - vec3(0.0, 0.0, e2), phase)));\n // A key light and a cool fill from the plasma side: flat faces take one tone\n // each, which is what makes the shard read as a cut stone rather than a blob.\n float key = max(dot(nrm, normalize(vec3(-0.45, 0.72, 0.52))), 0.0);\n float fill = max(dot(nrm, normalize(vec3(0.60, -0.20, -0.75))), 0.0);\n float rim = pow(1.0 - max(dot(nrm, -rd), 0.0), 4.0);\n col = mix(vec3(0.004, 0.016, 0.026), vec3(0.060, 0.290, 0.340), key);\n col += vec3(0.010, 0.060, 0.080) * fill;\n col += vec3(0.20, 0.62, 0.72) * rim * 0.55;\n }\n }\n }\n float alpha = (layer > 0.5 && layer < 1.5) ? solid : (0.42 + layer * 0.26) / 2.6;\n gl_FragColor = vec4(col, alpha);\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;\nconst float OBJECT_DEPTH = 0.68; // sheet 1 holds the shard, at a fixed depth\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}\nfloat fbm2(vec2 p) {\n return 0.5 * noise(p) + 0.25 * noise(mat2(1.62, 1.17, -1.17, 1.62) * p);\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// The slab's fracture lines: two families of long, slightly wandering cracks. Shared\n// by the height field and the sheen, so each crack is both a bright seam and a step.\nvec2 fracture(vec2 x) {\n float d = fbm(x * 0.62 + 3.0) - 0.5; // zero set: long winding cracks\n float e = fbm(x * 1.35 - 7.0) - 0.5; // a sparser second family\n return vec2(d, e);\n}\n// Frozen surface: fine ridged grain over the plates the cracks have lifted apart.\n// Static in world space; only the plasma behind it moves.\nfloat height(vec2 x) {\n vec2 fr = fracture(x);\n return 0.090 * (1.0 - abs(2.0 * fbm2(x * 2.2 + 2.0) - 1.0))\n + 0.055 * (1.0 - abs(2.0 * fbm2(x * 6.4 - 5.0) - 1.0))\n + 0.014 * clamp(fr.x * 110.0, -1.0, 1.0) // the slab sits at a different level\n + 0.009 * clamp(fr.y * 110.0, -1.0, 1.0); // on each side of a crack\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.008;\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.019)) - inside).xy;\n inside = normalize(inside);\n inside.z = min(inside.z, -0.40); // bound grazing rays at the crack steps\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 bool solid = i == 1; // sheet 1 is the shard: its alpha is coverage, not depth\n float depth = solid ? OBJECT_DEPTH\n : texture2D(u_scene, atlasUV(hit.xy + par, layer)).a * 2.6;\n float path = (hit.z - (FRONT - depth)) / -inside.z; // ice crossed to reach this sheet\n // The shard keeps its shape: it is sampled without the refraction offset, so it\n // stays one solid piece instead of being cut up by the crack steps.\n vec2 at = solid ? hit.xy + par * 0.5 : (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 // The shard is a solid, so its coverage comes straight from pass 1.\n float alpha = solid ? texture2D(u_scene, atlasUV(at, layer)).a * 0.92\n : 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 // Steep faces of the crests catch a thin sheen: the edge of the frozen surface,\n // painted only where the ice actually tilts, so the open water stays black.\n // Only the crack walls are this steep, so this sheen outlines the seams alone\n // instead of washing the whole grain.\n float tilt = smoothstep(1.00, 2.60, length(slope));\n col += vec3(0.022, 0.062, 0.078) * tilt;\n vec2 fr = fracture(hit.xy);\n float crack = (1.0 - smoothstep(0.0, 0.004, abs(fr.x)))\n + (1.0 - smoothstep(0.0, 0.004, abs(fr.y)));\n col += vec3(0.020, 0.055, 0.068) * crack * smoothstep(0.58, 0.80, fbm2(hit.xy * 0.30 + 21.0));\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>