Files
shaders/debug_fish.html
emil28092005 6dbcd630f4 Add frozen fish: dark silhouettes crossing the slab, cold blue eyes
Two of the eight sheets carry fish instead of only plasma. Each fish cuts
straight across the slab once per cycle (thirty seconds, period preserved),
three per sheet, sizes spread by pow(hash, 2.6) so most are small and now
and then one is huge. Sheets sit near the front (0 and 1) so the silhouettes
are not buried under the layers in front of them; their alpha carries
coverage instead of depth.

The fish ride screen space, since the sheets themselves stretch up to four
times towards the frame edges, and are bent by the smooth swell of the
surface only: the crack steps flip the slope within a pixel and would slice
a fish to bits. Eyes are a small cold blue glow, the only light they have.

debug_fish.frag renders the shapes flat for inspection.
2026-10-30 21:54:59 +03:00

113 lines
13 KiB
HTML
Raw Permalink 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 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.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 gl_FragColor = vec4(col, (0.42 + layer * 0.26) / 2.6);\n}\n";
const iceSource = "// Debug view: the fish silhouette alone, on flat grey, big enough to judge the\n// geometry. Not part of the pages; build with\n// python3 build_plasma.py 6_ice_plasma.frag debug_fish.frag debug_fish.html\nprecision highp float;\nuniform vec2 u_res;\nuniform float u_time;\n\nfloat hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }\nfloat smin(float a, float b, float k) {\n float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);\n return mix(b, a, h) - k * h * (1.0 - h);\n}\nfloat sdCapsule(vec2 p, vec2 a, vec2 b, float r) {\n vec2 pa = p - a, ba = b - a;\n float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);\n return length(pa - ba * h) - r;\n}\nfloat sdTriangle(vec2 p, vec2 p0, vec2 p1, vec2 p2) {\n vec2 e0 = p1 - p0, e1 = p2 - p1, e2 = p0 - p2;\n vec2 v0 = p - p0, v1 = p - p1, v2 = p - p2;\n vec2 q0 = v0 - e0 * clamp(dot(v0, e0) / dot(e0, e0), 0.0, 1.0);\n vec2 q1 = v1 - e1 * clamp(dot(v1, e1) / dot(e1, e1), 0.0, 1.0);\n vec2 q2 = v2 - e2 * clamp(dot(v2, e2) / dot(e2, e2), 0.0, 1.0);\n float s = sign(e0.x * e2.y - e0.y * e2.x);\n vec2 d = min(min(vec2(dot(q0, q0), s * (v0.x * e0.y - v0.y * e0.x)),\n vec2(dot(q1, q1), s * (v1.x * e1.y - v1.y * e1.x))),\n vec2(dot(q2, q2), s * (v2.x * e2.y - v2.y * e2.x)));\n return -sqrt(d.x) * sign(d.y);\n}\n// Half-height of the outline at x: pointed nose, deepest just behind the head,\n// a narrow peduncle, then the tail fan opening toward the rear. One function, so\n// the whole animal is guaranteed to be one connected shape.\n// Half-height of the body at x: pointed nose, deepest a quarter of the way back,\n// then down to a narrow peduncle. Sweeping a circle along it keeps the body one piece.\nfloat fishWidth(float x) {\n float t = clamp((1.15 - x) / 2.30, 0.0, 1.0); // 0 at the nose, 1 at the peduncle\n return 0.34 * pow(sin(3.14159 * pow(t, 0.55)), 0.8) + 0.05 * smoothstep(0.70, 1.0, t);\n}\nfloat sdFish(vec2 p) {\n float d = length(vec2(max(abs(p.x) - 1.15, 0.0), p.y)) - fishWidth(p.x); // body\n // tail: two lobes fanning back from the peduncle, with a notch between them\n d = smin(d, sdTriangle(p, vec2(-1.02, 0.03), vec2(-1.62, 0.09), vec2(-1.46, 0.52)), 0.08);\n d = smin(d, sdTriangle(p, vec2(-1.02, -0.03), vec2(-1.62, -0.09), vec2(-1.46, -0.52)), 0.08);\n // dorsal and pectoral fins\n d = smin(d, sdTriangle(p, vec2(0.22, 0.20), vec2(-0.06, 0.46), vec2(-0.40, 0.16)), 0.05);\n d = smin(d, sdTriangle(p, vec2(0.46, -0.16), vec2(0.26, -0.08), vec2(0.10, -0.28)), 0.04);\n return d;\n}\n\nvoid main() {\n vec2 p = (gl_FragCoord.xy - 0.5 * u_res) / u_res.y * 3.0;\n vec3 col = vec3(0.16, 0.17, 0.18);\n for (int i = 0; i < 3; i++) {\n float id = float(i);\n float size = mix(1.15, 0.28, id / 2.0);\n vec2 at = vec2(0.0, 0.75 - 0.72 * id);\n float d = sdFish((p - at) / size);\n col = mix(col, vec3(0.01), clamp(0.5 - d / (0.005 / size), 0.0, 1.0));\n }\n gl_FragColor = vec4(col, 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>