neuroalpha 0.5.17 → 0.5.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +61 -60
- package/public/admin.html +604 -571
- package/public/app.js +101 -22
- package/public/landing.html +1207 -1273
- package/public/legal.html +6 -5
- package/public/splat3d.html +671 -0
- package/public/styles.css +12 -3
- package/scripts/set-admin-password.mjs +30 -0
- package/server/api/routes.js +55 -16
- package/server/auth/index.js +58 -13
- package/server/billing/yookassa.js +142 -133
- package/server/brokers/index.js +84 -52
- package/server/data/moex.js +6 -5
- package/server/db.js +1 -1
- package/server/index.js +3 -3
- package/server/instance/central.js +514 -505
- package/server/model/dataset.js +8 -9
- package/server/model/edge.js +74 -19
- package/server/model/forecast.js +117 -20
- package/server/model/live.js +16 -18
- package/server/model/research.js +48 -17
- package/server/security.js +4 -1
- package/server/strategist/index.js +758 -757
- package/server/strategist/tools.js +707 -675
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="ru">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>НейроАльфа — 3D Gaussian Splatting</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #060911;
|
|
10
|
+
--panel: rgba(17, 24, 39, 0.72);
|
|
11
|
+
--line: rgba(255, 255, 255, 0.08);
|
|
12
|
+
--text: #f8fafc;
|
|
13
|
+
--muted: #94a3b8;
|
|
14
|
+
--accent: #3b82f6;
|
|
15
|
+
--red: #f43f5e;
|
|
16
|
+
}
|
|
17
|
+
* { box-sizing: border-box; }
|
|
18
|
+
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); font-family: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; overflow: hidden; }
|
|
19
|
+
canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; cursor: grab; touch-action: none; }
|
|
20
|
+
canvas:active { cursor: grabbing; }
|
|
21
|
+
.panel { position: fixed; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; backdrop-filter: blur(12px); padding: 12px 16px; }
|
|
22
|
+
.top { top: 16px; left: 16px; display: flex; align-items: center; gap: 14px; }
|
|
23
|
+
.top a { color: var(--text); text-decoration: none; font-weight: 700; }
|
|
24
|
+
.top .sub { color: var(--muted); font-size: 13px; }
|
|
25
|
+
.bottom { bottom: 16px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: center; }
|
|
26
|
+
button { background: rgba(255,255,255,0.06); color: var(--text); border: 1px solid var(--line); border-radius: 10px; padding: 8px 14px; font: inherit; font-size: 14px; cursor: pointer; }
|
|
27
|
+
button:hover { border-color: rgba(96, 165, 250, 0.5); }
|
|
28
|
+
button.primary { background: var(--accent); border-color: var(--accent); }
|
|
29
|
+
button.rec { background: var(--red); border-color: var(--red); }
|
|
30
|
+
button:disabled { opacity: 0.5; cursor: default; }
|
|
31
|
+
label { color: var(--muted); font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
|
32
|
+
.stats { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
33
|
+
.hint { position: fixed; right: 16px; top: 16px; color: var(--muted); font-size: 12px; text-align: right; }
|
|
34
|
+
.error { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; padding: 24px; text-align: center; color: var(--muted); }
|
|
35
|
+
</style>
|
|
36
|
+
</head>
|
|
37
|
+
<body>
|
|
38
|
+
<canvas id="gl"></canvas>
|
|
39
|
+
|
|
40
|
+
<div class="panel top">
|
|
41
|
+
<a href="/landing">◈ НейроАльфа</a>
|
|
42
|
+
<span class="sub">3D Gaussian Splatting · график и монеты</span>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div class="hint">Мышь — вращение · колесо — масштаб<br>Данные сгенерированы случайно</div>
|
|
46
|
+
|
|
47
|
+
<div class="panel bottom">
|
|
48
|
+
<button class="primary" id="btn-rec">● Записать 10 сек</button>
|
|
49
|
+
<button id="btn-new">↻ Новый график</button>
|
|
50
|
+
<label>Скорость облёта <input type="range" id="speed" min="0" max="3" step="0.1" value="1"></label>
|
|
51
|
+
<span class="stats" id="stats"></span>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div class="error" id="error">Ваш браузер не поддерживает WebGL2 — 3D-сцена недоступна.</div>
|
|
55
|
+
|
|
56
|
+
<script type="module">
|
|
57
|
+
// 3D Gaussian Splatting на чистом WebGL2: сцена (свечной график, скользящая средняя, монеты)
|
|
58
|
+
// целиком генерируется кодом и рендерится анизотропными гауссианами с сортировкой по глубине.
|
|
59
|
+
|
|
60
|
+
const TEX_W = 2048;
|
|
61
|
+
const CYCLE = 16;
|
|
62
|
+
const RECORD_SECONDS = 10;
|
|
63
|
+
|
|
64
|
+
// ---------- математика (матрицы column-major) ----------
|
|
65
|
+
|
|
66
|
+
const m4 = {
|
|
67
|
+
identity: () => new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
68
|
+
multiply(a, b) {
|
|
69
|
+
const o = new Float32Array(16);
|
|
70
|
+
for (let c = 0; c < 4; c++) {
|
|
71
|
+
for (let r = 0; r < 4; r++) {
|
|
72
|
+
o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return o;
|
|
76
|
+
},
|
|
77
|
+
chain: (...ms) => ms.reduce((a, b) => m4.multiply(a, b)),
|
|
78
|
+
translate: (x, y, z) => new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1]),
|
|
79
|
+
scale: (x, y, z) => new Float32Array([x, 0, 0, 0, 0, y, 0, 0, 0, 0, z, 0, 0, 0, 0, 1]),
|
|
80
|
+
rotX(a) { const c = Math.cos(a), s = Math.sin(a); return new Float32Array([1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1]); },
|
|
81
|
+
rotY(a) { const c = Math.cos(a), s = Math.sin(a); return new Float32Array([c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1]); },
|
|
82
|
+
perspective(fovy, aspect, near, far) {
|
|
83
|
+
const f = 1 / Math.tan(fovy / 2);
|
|
84
|
+
return new Float32Array([f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) / (near - far), -1, 0, 0, (2 * far * near) / (near - far), 0]);
|
|
85
|
+
},
|
|
86
|
+
lookAt(eye, target, up) {
|
|
87
|
+
const z = norm3(sub3(eye, target));
|
|
88
|
+
const x = norm3(cross3(up, z));
|
|
89
|
+
const y = cross3(z, x);
|
|
90
|
+
return new Float32Array([x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot3(x, eye), -dot3(y, eye), -dot3(z, eye), 1]);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
const sub3 = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
94
|
+
const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
95
|
+
const cross3 = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
96
|
+
const norm3 = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; };
|
|
97
|
+
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
|
98
|
+
const rnd = (a, b) => a + Math.random() * (b - a);
|
|
99
|
+
const gauss = () => Math.sqrt(-2 * Math.log(1 - Math.random())) * Math.cos(2 * Math.PI * Math.random());
|
|
100
|
+
const easeOutBack = (p) => { const c = 1.70158; return 1 + (c + 1) * (p - 1) ** 3 + c * (p - 1) ** 2; };
|
|
101
|
+
const quatZ = (a) => [Math.cos(a / 2), 0, 0, Math.sin(a / 2)];
|
|
102
|
+
const MIRROR_Y = m4.scale(1, -1, 1);
|
|
103
|
+
|
|
104
|
+
// ---------- построитель сплатов ----------
|
|
105
|
+
|
|
106
|
+
class SplatBuilder {
|
|
107
|
+
constructor() {
|
|
108
|
+
this.pos = []; this.cov = []; this.col = []; this.nrm = []; this.grp = [];
|
|
109
|
+
this.groups = [];
|
|
110
|
+
}
|
|
111
|
+
group() {
|
|
112
|
+
this.groups.push({ mat: m4.identity(), opacity: 1 });
|
|
113
|
+
return this.groups.length - 1;
|
|
114
|
+
}
|
|
115
|
+
// scale — полуоси гауссианы (σ), q — кватернион [w,x,y,z], normal — для освещения металлом
|
|
116
|
+
add(p, scale, q, color, alpha, group, normal) {
|
|
117
|
+
const [w, x, y, z] = q || [1, 0, 0, 0];
|
|
118
|
+
const R = [
|
|
119
|
+
1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y),
|
|
120
|
+
2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x),
|
|
121
|
+
2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y),
|
|
122
|
+
];
|
|
123
|
+
const M = [];
|
|
124
|
+
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) M[r * 3 + c] = R[r * 3 + c] * scale[c];
|
|
125
|
+
const s = (i, j) => M[i * 3] * M[j * 3] + M[i * 3 + 1] * M[j * 3 + 1] + M[i * 3 + 2] * M[j * 3 + 2];
|
|
126
|
+
this.pos.push(p[0], p[1], p[2]);
|
|
127
|
+
this.cov.push(s(0, 0), s(0, 1), s(0, 2), s(1, 1), s(1, 2), s(2, 2));
|
|
128
|
+
this.col.push(color[0], color[1], color[2], alpha);
|
|
129
|
+
if (normal) this.nrm.push(normal[0], normal[1], normal[2], 1);
|
|
130
|
+
else this.nrm.push(0, 0, 0, 0);
|
|
131
|
+
this.grp.push(group);
|
|
132
|
+
}
|
|
133
|
+
get count() { return this.grp.length; }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------- генерация данных и сцены ----------
|
|
137
|
+
|
|
138
|
+
function generateCandles(n) {
|
|
139
|
+
const out = [];
|
|
140
|
+
let price = 100;
|
|
141
|
+
const drift = rnd(-0.002, 0.004);
|
|
142
|
+
for (let i = 0; i < n; i++) {
|
|
143
|
+
const vol = 0.012 + 0.01 * Math.abs(Math.sin(i * 0.37));
|
|
144
|
+
const o = price;
|
|
145
|
+
const c = o * (1 + drift + vol * gauss());
|
|
146
|
+
const h = Math.max(o, c) * (1 + Math.abs(gauss()) * vol * 0.6);
|
|
147
|
+
const l = Math.min(o, c) * (1 - Math.abs(gauss()) * vol * 0.6);
|
|
148
|
+
out.push({ o, h, l, c });
|
|
149
|
+
price = c;
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function makeGlyphMask(char) {
|
|
155
|
+
const size = 128;
|
|
156
|
+
const cv = document.createElement('canvas');
|
|
157
|
+
cv.width = cv.height = size;
|
|
158
|
+
const ctx = cv.getContext('2d');
|
|
159
|
+
ctx.fillStyle = '#fff';
|
|
160
|
+
ctx.font = 'bold 96px Arial, sans-serif';
|
|
161
|
+
ctx.textAlign = 'center';
|
|
162
|
+
ctx.textBaseline = 'middle';
|
|
163
|
+
ctx.fillText(char, size / 2, size / 2 + 6);
|
|
164
|
+
const data = ctx.getImageData(0, 0, size, size).data;
|
|
165
|
+
return (u, v) => {
|
|
166
|
+
const px = Math.round(size / 2 + u * 52), py = Math.round(size / 2 - v * 52);
|
|
167
|
+
if (px < 0 || py < 0 || px >= size || py >= size) return false;
|
|
168
|
+
return data[(py * size + px) * 4 + 3] > 128;
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function buildScene() {
|
|
173
|
+
const b = new SplatBuilder();
|
|
174
|
+
const anim = { candles: [], ma: [], coins: [], dust: 0 };
|
|
175
|
+
|
|
176
|
+
// пол-сетка и мягкое свечение под графиком
|
|
177
|
+
const floor = b.group();
|
|
178
|
+
for (let k = -7; k <= 7; k += 0.5) {
|
|
179
|
+
for (let t = -7; t <= 7; t += 0.04) {
|
|
180
|
+
for (const [x, z, sc] of [[t, k, [0.03, 0.002, 0.004]], [k, t, [0.004, 0.002, 0.03]]]) {
|
|
181
|
+
const a = 0.35 * Math.exp(-(x * x + z * z) / 18);
|
|
182
|
+
if (a > 0.01) b.add([x, 0, z], sc, null, [0.2, 0.45, 0.95], a, floor);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (let i = 0; i < 220; i++) {
|
|
187
|
+
const r = Math.sqrt(Math.random()) * 4.5, a = Math.random() * Math.PI * 2;
|
|
188
|
+
b.add([Math.cos(a) * r * 1.4, -0.01, Math.sin(a) * r * 0.6], [0.5, 0.01, 0.5], null, [0.1, 0.3, 0.85], 0.025, floor);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// пыль и туманности
|
|
192
|
+
const dust = b.group();
|
|
193
|
+
anim.dust = dust;
|
|
194
|
+
const dustColors = [[0.25, 0.8, 1], [1, 0.8, 0.4], [0.45, 0.5, 1]];
|
|
195
|
+
for (let i = 0; i < 3000; i++) {
|
|
196
|
+
const r = rnd(3, 13), a = Math.random() * Math.PI * 2;
|
|
197
|
+
const s = rnd(0.008, 0.025);
|
|
198
|
+
b.add([Math.cos(a) * r, rnd(-1, 8), Math.sin(a) * r], [s, s, s], null, dustColors[i % 3], rnd(0.25, 0.8), dust);
|
|
199
|
+
}
|
|
200
|
+
for (let i = 0; i < 40; i++) {
|
|
201
|
+
const r = rnd(9, 16), a = Math.random() * Math.PI * 2;
|
|
202
|
+
const s = rnd(1, 2.2);
|
|
203
|
+
b.add([Math.cos(a) * r, rnd(0, 7), Math.sin(a) * r], [s, s * 0.6, s], null, i % 2 ? [0.25, 0.15, 0.6] : [0.05, 0.25, 0.6], 0.035, dust);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// свечной график
|
|
207
|
+
const N = 36, spacing = 0.2, halfW = 0.065;
|
|
208
|
+
const data = generateCandles(N);
|
|
209
|
+
const lo = Math.min(...data.map((d) => d.l)), hi = Math.max(...data.map((d) => d.h));
|
|
210
|
+
const P = (price) => 0.3 + ((price - lo) / (hi - lo)) * 2.9;
|
|
211
|
+
const xOf = (i) => (i - (N - 1) / 2) * spacing;
|
|
212
|
+
|
|
213
|
+
// каждая точка дублируется в «отражение» под полом
|
|
214
|
+
const addMirrored = (g, gr, p, sc, q, color, alpha) => {
|
|
215
|
+
b.add(p, sc, q, color, alpha, g);
|
|
216
|
+
b.add(p, sc, q, color.map((v) => v * 0.8), alpha * 0.28 * Math.exp(-p[1] * 0.6), gr);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
data.forEach((d, i) => {
|
|
220
|
+
const g = b.group(), gr = b.group();
|
|
221
|
+
const up = d.c >= d.o;
|
|
222
|
+
const base = up ? [0.13, 0.95, 0.6] : [1.0, 0.25, 0.38];
|
|
223
|
+
const cx = xOf(i);
|
|
224
|
+
const y0 = P(Math.min(d.o, d.c));
|
|
225
|
+
const y1 = Math.max(P(Math.max(d.o, d.c)), y0 + 0.03);
|
|
226
|
+
const hgt = y1 - y0;
|
|
227
|
+
anim.candles.push({ g, gr, cx, baseY: P(d.l) });
|
|
228
|
+
|
|
229
|
+
const faces = [
|
|
230
|
+
{ area: hgt * 2 * halfW, n: [1, 0, 0] }, { area: hgt * 2 * halfW, n: [-1, 0, 0] },
|
|
231
|
+
{ area: hgt * 2 * halfW, n: [0, 0, 1] }, { area: hgt * 2 * halfW, n: [0, 0, -1] },
|
|
232
|
+
{ area: 4 * halfW * halfW, n: [0, 1, 0] }, { area: 4 * halfW * halfW, n: [0, -1, 0] },
|
|
233
|
+
];
|
|
234
|
+
const total = faces.reduce((s, f) => s + f.area, 0);
|
|
235
|
+
const nSurf = Math.min(1400, Math.round(300 + hgt * 900));
|
|
236
|
+
for (let k = 0; k < nSurf; k++) {
|
|
237
|
+
let r = Math.random() * total, f = faces[0];
|
|
238
|
+
for (const ff of faces) { if ((r -= ff.area) <= 0) { f = ff; break; } }
|
|
239
|
+
let x = rnd(-halfW, halfW), y = rnd(y0, y1), z = rnd(-halfW, halfW);
|
|
240
|
+
if (f.n[0]) x = f.n[0] * halfW;
|
|
241
|
+
if (f.n[1]) y = f.n[1] > 0 ? y1 : y0;
|
|
242
|
+
if (f.n[2]) z = f.n[2] * halfW;
|
|
243
|
+
const edge = [halfW - Math.abs(x), Math.min(y - y0, y1 - y), halfW - Math.abs(z)].sort((p, q) => p - q)[1] < 0.012;
|
|
244
|
+
const sc = [f.n[0] ? 0.003 : 0.012, f.n[1] ? 0.003 : 0.012, f.n[2] ? 0.003 : 0.012];
|
|
245
|
+
const k2 = edge ? 1.5 : rnd(0.7, 1.05);
|
|
246
|
+
addMirrored(g, gr, [cx + x, y, z], sc, null, base.map((v) => v * k2), edge ? 0.95 : 0.7);
|
|
247
|
+
}
|
|
248
|
+
for (let k = 0; k < nSurf * 0.25; k++) {
|
|
249
|
+
addMirrored(g, gr, [cx + rnd(-halfW, halfW) * 0.8, rnd(y0, y1), rnd(-halfW, halfW) * 0.8], [0.02, 0.02, 0.02], null,
|
|
250
|
+
base.map((v) => v * 1.1 + 0.2), 0.25);
|
|
251
|
+
}
|
|
252
|
+
for (let k = 0; k < 60 + hgt * 120; k++) {
|
|
253
|
+
const s = rnd(0.06, 0.1);
|
|
254
|
+
addMirrored(g, gr, [cx + rnd(-halfW, halfW), rnd(y0, y1), rnd(-halfW, halfW)], [s, s, s], null, base, 0.05);
|
|
255
|
+
}
|
|
256
|
+
const wl = P(d.l), wh = P(d.h);
|
|
257
|
+
for (let y = wl; y <= wh; y += 0.008) {
|
|
258
|
+
addMirrored(g, gr, [cx, y, 0], [0.007, 0.012, 0.007], null, base.map((v) => v * 0.9 + 0.1), 0.85);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// скользящая средняя (SMA 7) — светящаяся золотая линия по сплайну Catmull-Rom
|
|
263
|
+
const period = 7;
|
|
264
|
+
const pts = [];
|
|
265
|
+
for (let i = period - 1; i < N; i++) {
|
|
266
|
+
const avg = data.slice(i - period + 1, i + 1).reduce((s, d) => s + d.c, 0) / period;
|
|
267
|
+
pts.push({ i, x: xOf(i), y: P(avg) });
|
|
268
|
+
}
|
|
269
|
+
for (let s = 0; s < pts.length - 1; s++) {
|
|
270
|
+
const g = b.group(), gr = b.group();
|
|
271
|
+
anim.ma.push({ g, gr, candle: pts[s + 1].i });
|
|
272
|
+
const p0 = pts[Math.max(0, s - 1)], p1 = pts[s], p2 = pts[s + 1], p3 = pts[Math.min(pts.length - 1, s + 2)];
|
|
273
|
+
const cr = (t, a, b1, c, d) => 0.5 * (2 * b1 + (-a + c) * t + (2 * a - 5 * b1 + 4 * c - d) * t * t + (-a + 3 * b1 - 3 * c + d) * t * t * t);
|
|
274
|
+
for (let k = 0; k < 24; k++) {
|
|
275
|
+
const t = k / 24, t2 = t + 0.01;
|
|
276
|
+
const x = cr(t, p0.x, p1.x, p2.x, p3.x), y = cr(t, p0.y, p1.y, p2.y, p3.y);
|
|
277
|
+
const ang = Math.atan2(cr(t2, p0.y, p1.y, p2.y, p3.y) - y, cr(t2, p0.x, p1.x, p2.x, p3.x) - x);
|
|
278
|
+
addMirrored(g, gr, [x, y, 0.1], [0.018, 0.007, 0.007], quatZ(ang), [1, 0.8, 0.3], 0.9);
|
|
279
|
+
if (k % 3 === 0) addMirrored(g, gr, [x, y, 0.1], [0.05, 0.05, 0.05], null, [1, 0.7, 0.2], 0.08);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// монеты с отчеканенным знаком рубля
|
|
284
|
+
const glyph = makeGlyphMask('₽');
|
|
285
|
+
const R = 0.36, H = 0.055;
|
|
286
|
+
for (let c = 0; c < 6; c++) {
|
|
287
|
+
const g = b.group();
|
|
288
|
+
const metal = c % 3 === 2 ? [0.78, 0.82, 0.88] : [1.0, 0.72, 0.28];
|
|
289
|
+
anim.coins.push({ g, phase: (c / 6) * Math.PI * 2, size: rnd(0.95, 1.25) });
|
|
290
|
+
const jitter = (n, k) => norm3([n[0] + gauss() * k, n[1] + gauss() * k, n[2] + gauss() * k]);
|
|
291
|
+
for (const side of [1, -1]) {
|
|
292
|
+
for (let k = 0; k < 1600; k++) {
|
|
293
|
+
const r = Math.sqrt(Math.random()) * R, a = Math.random() * Math.PI * 2;
|
|
294
|
+
b.add([Math.cos(a) * r, Math.sin(a) * r, (side * H) / 2], [0.016, 0.016, 0.003], null,
|
|
295
|
+
metal.map((v) => v * rnd(0.85, 1)), 0.9, g, jitter([0, 0, side], 0.08));
|
|
296
|
+
}
|
|
297
|
+
let placed = 0;
|
|
298
|
+
for (let tries = 0; placed < 900 && tries < 20000; tries++) {
|
|
299
|
+
const u = rnd(-1, 1), v = rnd(-1, 1);
|
|
300
|
+
if (!glyph(side > 0 ? u : -u, v)) continue;
|
|
301
|
+
b.add([u * 0.24, v * 0.24, side * (H / 2 + 0.006)], [0.01, 0.01, 0.004], null,
|
|
302
|
+
metal.map((x) => x * 1.1), 0.95, g, jitter([0, 0, side], 0.35));
|
|
303
|
+
placed++;
|
|
304
|
+
}
|
|
305
|
+
for (let k = 0; k < 500; k++) {
|
|
306
|
+
const r = rnd(0.305, 0.345), a = Math.random() * Math.PI * 2;
|
|
307
|
+
b.add([Math.cos(a) * r, Math.sin(a) * r, side * (H / 2 + 0.004)], [0.006, 0.014, 0.004], quatZ(a),
|
|
308
|
+
metal, 0.95, g, norm3([Math.cos(a) * 0.4, Math.sin(a) * 0.4, side]));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
for (let k = 0; k < 450; k++) {
|
|
312
|
+
const a = Math.random() * Math.PI * 2;
|
|
313
|
+
b.add([Math.cos(a) * R, Math.sin(a) * R, rnd(-H / 2, H / 2)], [0.003, 0.02, 0.01], quatZ(a),
|
|
314
|
+
metal.map((v) => v * 0.9), 0.95, g, [Math.cos(a), Math.sin(a), 0]);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { builder: b, anim };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------- WebGL ----------
|
|
322
|
+
|
|
323
|
+
const VS = `#version 300 es
|
|
324
|
+
precision highp float;
|
|
325
|
+
precision highp int;
|
|
326
|
+
precision highp sampler2D;
|
|
327
|
+
uniform sampler2D uCenter, uCovA, uCovB, uColor, uNormal, uGroups;
|
|
328
|
+
uniform mat4 uView, uProj;
|
|
329
|
+
uniform vec2 uFocal, uViewport;
|
|
330
|
+
uniform vec3 uCamPos;
|
|
331
|
+
layout(location = 0) in vec2 aQuad;
|
|
332
|
+
layout(location = 1) in uint aIndex;
|
|
333
|
+
out vec4 vColor;
|
|
334
|
+
out vec2 vPos;
|
|
335
|
+
|
|
336
|
+
vec3 shade(vec3 base, vec3 n, vec3 V) {
|
|
337
|
+
if (dot(n, V) < 0.0) n = -n;
|
|
338
|
+
vec3 L1 = normalize(vec3(0.6, 1.0, 0.5));
|
|
339
|
+
vec3 L2 = normalize(vec3(-0.8, 0.3, -0.6));
|
|
340
|
+
vec3 R = reflect(-V, n);
|
|
341
|
+
vec3 env = mix(vec3(0.05, 0.08, 0.16), vec3(0.95, 0.88, 0.75), smoothstep(-0.1, 0.9, R.y));
|
|
342
|
+
env += vec3(0.1, 0.5, 0.9) * pow(max(dot(R, L2), 0.0), 8.0) * 0.6;
|
|
343
|
+
float d1 = max(dot(n, L1), 0.0), d2 = max(dot(n, L2), 0.0);
|
|
344
|
+
float s1 = pow(max(dot(n, normalize(L1 + V)), 0.0), 80.0);
|
|
345
|
+
float s2 = pow(max(dot(n, normalize(L2 + V)), 0.0), 40.0);
|
|
346
|
+
return base * (0.12 + 0.5 * d1 + 0.2 * d2) + base * env * 0.75
|
|
347
|
+
+ vec3(1.0, 0.95, 0.85) * s1 * 1.6 + vec3(0.4, 0.75, 1.0) * s2 * 0.8;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
void main() {
|
|
351
|
+
ivec2 t = ivec2(int(aIndex & 2047u), int(aIndex >> 11u));
|
|
352
|
+
vec4 c = texelFetch(uCenter, t, 0);
|
|
353
|
+
int g = int(c.w + 0.5);
|
|
354
|
+
mat4 M = mat4(texelFetch(uGroups, ivec2(0, g), 0), texelFetch(uGroups, ivec2(1, g), 0),
|
|
355
|
+
texelFetch(uGroups, ivec2(2, g), 0), texelFetch(uGroups, ivec2(3, g), 0));
|
|
356
|
+
vec4 col = texelFetch(uColor, t, 0);
|
|
357
|
+
col.a *= texelFetch(uGroups, ivec2(4, g), 0).x;
|
|
358
|
+
vec4 world = M * vec4(c.xyz, 1.0);
|
|
359
|
+
vec4 cam = uView * world;
|
|
360
|
+
if (col.a < 0.004 || cam.z > -0.1) { gl_Position = vec4(0.0, 0.0, 2.0, 1.0); return; }
|
|
361
|
+
vec4 clip = uProj * cam;
|
|
362
|
+
vec2 ndc = clip.xy / clip.w;
|
|
363
|
+
if (any(greaterThan(abs(ndc), vec2(1.3)))) { gl_Position = vec4(0.0, 0.0, 2.0, 1.0); return; }
|
|
364
|
+
|
|
365
|
+
vec4 n = texelFetch(uNormal, t, 0);
|
|
366
|
+
if (n.w > 0.5) col.rgb = shade(col.rgb, normalize(mat3(M) * n.xyz), normalize(uCamPos - world.xyz));
|
|
367
|
+
|
|
368
|
+
vec4 a = texelFetch(uCovA, t, 0);
|
|
369
|
+
vec4 b = texelFetch(uCovB, t, 0);
|
|
370
|
+
mat3 Vr = mat3(a.x, a.y, a.z, a.y, a.w, b.x, a.z, b.x, b.y);
|
|
371
|
+
mat3 RM = mat3(M);
|
|
372
|
+
Vr = RM * Vr * transpose(RM);
|
|
373
|
+
float z = cam.z;
|
|
374
|
+
mat3 J = mat3(uFocal.x / -z, 0.0, 0.0,
|
|
375
|
+
0.0, uFocal.y / -z, 0.0,
|
|
376
|
+
uFocal.x * cam.x / (z * z), uFocal.y * cam.y / (z * z), 0.0);
|
|
377
|
+
mat3 T = J * mat3(uView);
|
|
378
|
+
mat3 cov = T * Vr * transpose(T);
|
|
379
|
+
|
|
380
|
+
float A = cov[0][0] + 0.3, D = cov[1][1] + 0.3, B = cov[0][1];
|
|
381
|
+
float mid = 0.5 * (A + D);
|
|
382
|
+
float rad = length(vec2(0.5 * (A - D), B));
|
|
383
|
+
float l1 = mid + rad, l2 = max(mid - rad, 0.1);
|
|
384
|
+
vec2 dir = abs(B) < 1e-6 ? (A >= D ? vec2(1.0, 0.0) : vec2(0.0, 1.0)) : normalize(vec2(B, l1 - A));
|
|
385
|
+
vec2 major = min(sqrt(2.0 * l1), 1024.0) * dir;
|
|
386
|
+
vec2 minor = min(sqrt(2.0 * l2), 1024.0) * vec2(dir.y, -dir.x);
|
|
387
|
+
gl_Position = vec4(ndc + (aQuad.x * major + aQuad.y * minor) * 2.0 / uViewport, 0.0, 1.0);
|
|
388
|
+
vColor = col;
|
|
389
|
+
vPos = aQuad;
|
|
390
|
+
}`;
|
|
391
|
+
|
|
392
|
+
const FS = `#version 300 es
|
|
393
|
+
precision highp float;
|
|
394
|
+
in vec4 vColor;
|
|
395
|
+
in vec2 vPos;
|
|
396
|
+
out vec4 fragColor;
|
|
397
|
+
void main() {
|
|
398
|
+
float A = -dot(vPos, vPos);
|
|
399
|
+
if (A < -4.0) discard;
|
|
400
|
+
float B = exp(A) * vColor.a;
|
|
401
|
+
fragColor = vec4(vColor.rgb * B, B);
|
|
402
|
+
}`;
|
|
403
|
+
|
|
404
|
+
const canvas = document.getElementById('gl');
|
|
405
|
+
const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, premultipliedAlpha: true });
|
|
406
|
+
if (!gl) {
|
|
407
|
+
document.getElementById('error').style.display = 'flex';
|
|
408
|
+
throw new Error('WebGL2 недоступен');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function compile(type, src) {
|
|
412
|
+
const s = gl.createShader(type);
|
|
413
|
+
gl.shaderSource(s, src);
|
|
414
|
+
gl.compileShader(s);
|
|
415
|
+
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
|
|
416
|
+
return s;
|
|
417
|
+
}
|
|
418
|
+
const prog = gl.createProgram();
|
|
419
|
+
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
|
|
420
|
+
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FS));
|
|
421
|
+
gl.linkProgram(prog);
|
|
422
|
+
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
|
|
423
|
+
gl.useProgram(prog);
|
|
424
|
+
const U = Object.fromEntries(['uCenter', 'uCovA', 'uCovB', 'uColor', 'uNormal', 'uGroups', 'uView', 'uProj', 'uFocal', 'uViewport', 'uCamPos']
|
|
425
|
+
.map((n) => [n, gl.getUniformLocation(prog, n)]));
|
|
426
|
+
|
|
427
|
+
const vao = gl.createVertexArray();
|
|
428
|
+
gl.bindVertexArray(vao);
|
|
429
|
+
const quadBuf = gl.createBuffer();
|
|
430
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
|
|
431
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-2, -2, 2, -2, -2, 2, 2, 2]), gl.STATIC_DRAW);
|
|
432
|
+
gl.enableVertexAttribArray(0);
|
|
433
|
+
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
|
434
|
+
const indexBuf = gl.createBuffer();
|
|
435
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, indexBuf);
|
|
436
|
+
gl.enableVertexAttribArray(1);
|
|
437
|
+
gl.vertexAttribIPointer(1, 1, gl.UNSIGNED_INT, 0, 0);
|
|
438
|
+
gl.vertexAttribDivisor(1, 1);
|
|
439
|
+
|
|
440
|
+
function makeTexture(unit, uniform) {
|
|
441
|
+
const tex = gl.createTexture();
|
|
442
|
+
gl.activeTexture(gl.TEXTURE0 + unit);
|
|
443
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
444
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
445
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
446
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
447
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
448
|
+
gl.uniform1i(U[uniform], unit);
|
|
449
|
+
return { tex, unit };
|
|
450
|
+
}
|
|
451
|
+
const T = {
|
|
452
|
+
center: makeTexture(0, 'uCenter'), covA: makeTexture(1, 'uCovA'), covB: makeTexture(2, 'uCovB'),
|
|
453
|
+
color: makeTexture(3, 'uColor'), normal: makeTexture(4, 'uNormal'), groups: makeTexture(5, 'uGroups'),
|
|
454
|
+
};
|
|
455
|
+
function upload(t, w, h, data) {
|
|
456
|
+
gl.activeTexture(gl.TEXTURE0 + t.unit);
|
|
457
|
+
gl.bindTexture(gl.TEXTURE_2D, t.tex);
|
|
458
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, w, h, 0, gl.RGBA, gl.FLOAT, data);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---------- загрузка сцены на GPU ----------
|
|
462
|
+
|
|
463
|
+
let scene, N, localPos, groupOf, groupData, indices, depths, keys, visible, counts;
|
|
464
|
+
|
|
465
|
+
function loadScene() {
|
|
466
|
+
scene = buildScene();
|
|
467
|
+
const b = scene.builder;
|
|
468
|
+
N = b.count;
|
|
469
|
+
const H = Math.ceil(N / TEX_W), size = TEX_W * H * 4;
|
|
470
|
+
const center = new Float32Array(size), covA = new Float32Array(size), covB = new Float32Array(size);
|
|
471
|
+
const color = new Float32Array(size), normal = new Float32Array(size);
|
|
472
|
+
for (let i = 0; i < N; i++) {
|
|
473
|
+
center.set([b.pos[i * 3], b.pos[i * 3 + 1], b.pos[i * 3 + 2], b.grp[i]], i * 4);
|
|
474
|
+
covA.set([b.cov[i * 6], b.cov[i * 6 + 1], b.cov[i * 6 + 2], b.cov[i * 6 + 3]], i * 4);
|
|
475
|
+
covB.set([b.cov[i * 6 + 4], b.cov[i * 6 + 5], 0, 0], i * 4);
|
|
476
|
+
}
|
|
477
|
+
color.set(b.col);
|
|
478
|
+
normal.set(b.nrm);
|
|
479
|
+
upload(T.center, TEX_W, H, center);
|
|
480
|
+
upload(T.covA, TEX_W, H, covA);
|
|
481
|
+
upload(T.covB, TEX_W, H, covB);
|
|
482
|
+
upload(T.color, TEX_W, H, color);
|
|
483
|
+
upload(T.normal, TEX_W, H, normal);
|
|
484
|
+
|
|
485
|
+
localPos = new Float32Array(b.pos);
|
|
486
|
+
groupOf = new Uint16Array(b.grp);
|
|
487
|
+
groupData = new Float32Array(b.groups.length * 20);
|
|
488
|
+
indices = new Uint32Array(N);
|
|
489
|
+
visible = new Uint32Array(N);
|
|
490
|
+
depths = new Float32Array(N);
|
|
491
|
+
keys = new Uint16Array(N);
|
|
492
|
+
counts = new Uint32Array(65536);
|
|
493
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, indexBuf);
|
|
494
|
+
gl.bufferData(gl.ARRAY_BUFFER, indices.byteLength, gl.DYNAMIC_DRAW);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---------- анимация ----------
|
|
498
|
+
|
|
499
|
+
function animate(t, tc) {
|
|
500
|
+
const { groups } = scene.builder;
|
|
501
|
+
const { anim } = scene;
|
|
502
|
+
const fadeOut = 1 - clamp01((tc - (CYCLE - 1)) / 1);
|
|
503
|
+
const startOf = (i) => 0.5 + i * 0.11;
|
|
504
|
+
|
|
505
|
+
anim.candles.forEach((c, i) => {
|
|
506
|
+
const p = clamp01((tc - startOf(i)) / 0.7);
|
|
507
|
+
const s = Math.max(0.001, easeOutBack(p));
|
|
508
|
+
const sxz = Math.min(1, 0.3 + s);
|
|
509
|
+
const mat = m4.chain(m4.translate(c.cx, c.baseY, 0), m4.scale(sxz, s, sxz), m4.translate(-c.cx, -c.baseY, 0));
|
|
510
|
+
const op = clamp01((tc - startOf(i)) / 0.25) * fadeOut;
|
|
511
|
+
groups[c.g].mat = mat; groups[c.g].opacity = op;
|
|
512
|
+
groups[c.gr].mat = m4.multiply(MIRROR_Y, mat); groups[c.gr].opacity = op;
|
|
513
|
+
});
|
|
514
|
+
for (const m of anim.ma) {
|
|
515
|
+
const op = clamp01((tc - startOf(m.candle) - 0.2) / 0.4) * fadeOut;
|
|
516
|
+
groups[m.g].opacity = op;
|
|
517
|
+
groups[m.gr].mat = MIRROR_Y; groups[m.gr].opacity = op;
|
|
518
|
+
}
|
|
519
|
+
for (const c of anim.coins) {
|
|
520
|
+
const a = c.phase + t * 0.25;
|
|
521
|
+
const pos = [Math.cos(a) * 4.6, 1.7 + 0.8 * Math.sin(t * 0.8 + c.phase * 3), Math.sin(a) * 4.6];
|
|
522
|
+
groups[c.g].mat = m4.chain(m4.translate(...pos), m4.rotY(t * 1.6 + c.phase), m4.rotX(0.35 * Math.sin(t + c.phase)),
|
|
523
|
+
m4.scale(c.size, c.size, c.size));
|
|
524
|
+
}
|
|
525
|
+
groups[anim.dust].mat = m4.rotY(t * 0.03);
|
|
526
|
+
|
|
527
|
+
groups.forEach((g, i) => {
|
|
528
|
+
groupData.set(g.mat, i * 20);
|
|
529
|
+
groupData[i * 20 + 16] = g.opacity;
|
|
530
|
+
});
|
|
531
|
+
upload(T.groups, 5, groups.length, groupData);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// сортировка сплатов от дальних к ближним (counting sort по 16-битной глубине)
|
|
535
|
+
function sortSplats(view) {
|
|
536
|
+
const { groups } = scene.builder;
|
|
537
|
+
const zc = new Float32Array(groups.length * 4);
|
|
538
|
+
const alive = new Uint8Array(groups.length);
|
|
539
|
+
groups.forEach((g, i) => {
|
|
540
|
+
const m = g.mat;
|
|
541
|
+
for (let c = 0; c < 4; c++) zc[i * 4 + c] = view[2] * m[c * 4] + view[6] * m[c * 4 + 1] + view[10] * m[c * 4 + 2] + view[14] * m[c * 4 + 3];
|
|
542
|
+
alive[i] = g.opacity > 0.004 ? 1 : 0;
|
|
543
|
+
});
|
|
544
|
+
let n = 0, minZ = Infinity, maxZ = -Infinity;
|
|
545
|
+
for (let i = 0; i < N; i++) {
|
|
546
|
+
const g = groupOf[i];
|
|
547
|
+
if (!alive[g]) continue;
|
|
548
|
+
const k = g * 4;
|
|
549
|
+
const z = zc[k] * localPos[i * 3] + zc[k + 1] * localPos[i * 3 + 1] + zc[k + 2] * localPos[i * 3 + 2] + zc[k + 3];
|
|
550
|
+
depths[n] = z; visible[n++] = i;
|
|
551
|
+
if (z < minZ) minZ = z;
|
|
552
|
+
if (z > maxZ) maxZ = z;
|
|
553
|
+
}
|
|
554
|
+
const scale = 65535 / (maxZ - minZ || 1);
|
|
555
|
+
counts.fill(0);
|
|
556
|
+
for (let j = 0; j < n; j++) { const k = ((depths[j] - minZ) * scale) | 0; keys[j] = k; counts[k]++; }
|
|
557
|
+
for (let k = 0, sum = 0; k < 65536; k++) { const c = counts[k]; counts[k] = sum; sum += c; }
|
|
558
|
+
for (let j = 0; j < n; j++) indices[counts[keys[j]]++] = visible[j];
|
|
559
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, indexBuf);
|
|
560
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, 0, indices, 0, n);
|
|
561
|
+
return n;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ---------- камера и управление ----------
|
|
565
|
+
|
|
566
|
+
const cam = { az: 0, el: 0, dist: 8.5, dragging: false, lx: 0, ly: 0, speed: 1, autoAz: 0 };
|
|
567
|
+
canvas.addEventListener('pointerdown', (e) => { cam.dragging = true; cam.lx = e.clientX; cam.ly = e.clientY; canvas.setPointerCapture(e.pointerId); });
|
|
568
|
+
canvas.addEventListener('pointerup', () => { cam.dragging = false; });
|
|
569
|
+
canvas.addEventListener('pointermove', (e) => {
|
|
570
|
+
if (!cam.dragging) return;
|
|
571
|
+
cam.az -= (e.clientX - cam.lx) * 0.005;
|
|
572
|
+
cam.el = Math.max(-0.25, Math.min(0.9, cam.el + (e.clientY - cam.ly) * 0.004));
|
|
573
|
+
cam.lx = e.clientX; cam.ly = e.clientY;
|
|
574
|
+
});
|
|
575
|
+
canvas.addEventListener('wheel', (e) => { e.preventDefault(); cam.dist = Math.max(3.5, Math.min(16, cam.dist * Math.exp(e.deltaY * 0.001))); }, { passive: false });
|
|
576
|
+
document.getElementById('speed').addEventListener('input', (e) => { cam.speed = Number(e.target.value); });
|
|
577
|
+
|
|
578
|
+
function resize() {
|
|
579
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
580
|
+
const w = Math.round(canvas.clientWidth * dpr), h = Math.round(canvas.clientHeight * dpr);
|
|
581
|
+
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ---------- кадр ----------
|
|
585
|
+
|
|
586
|
+
const stats = document.getElementById('stats');
|
|
587
|
+
let timeOrigin = performance.now(), lastFrame = performance.now(), fps = 60;
|
|
588
|
+
|
|
589
|
+
function frame(now) {
|
|
590
|
+
resize();
|
|
591
|
+
const dt = Math.min(0.1, (now - lastFrame) / 1000);
|
|
592
|
+
lastFrame = now;
|
|
593
|
+
fps = fps * 0.95 + (1 / Math.max(dt, 1e-3)) * 0.05;
|
|
594
|
+
const t = (now - timeOrigin) / 1000;
|
|
595
|
+
if (!cam.dragging) cam.autoAz += dt * 0.18 * cam.speed;
|
|
596
|
+
|
|
597
|
+
const target = [0, 1.5, 0];
|
|
598
|
+
const az = cam.az + cam.autoAz, el = 0.32 + 0.1 * Math.sin(t * 0.3) + cam.el;
|
|
599
|
+
const eye = [target[0] + cam.dist * Math.cos(el) * Math.sin(az), target[1] + cam.dist * Math.sin(el), target[2] + cam.dist * Math.cos(el) * Math.cos(az)];
|
|
600
|
+
const fov = (45 * Math.PI) / 180;
|
|
601
|
+
const view = m4.lookAt(eye, target, [0, 1, 0]);
|
|
602
|
+
const proj = m4.perspective(fov, canvas.width / canvas.height, 0.1, 100);
|
|
603
|
+
const fy = canvas.height / (2 * Math.tan(fov / 2));
|
|
604
|
+
|
|
605
|
+
animate(t, t % CYCLE);
|
|
606
|
+
const n = sortSplats(view);
|
|
607
|
+
|
|
608
|
+
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
609
|
+
gl.clearColor(0.024, 0.035, 0.067, 1);
|
|
610
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
611
|
+
gl.disable(gl.DEPTH_TEST);
|
|
612
|
+
gl.enable(gl.BLEND);
|
|
613
|
+
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
|
614
|
+
gl.useProgram(prog);
|
|
615
|
+
gl.uniformMatrix4fv(U.uView, false, view);
|
|
616
|
+
gl.uniformMatrix4fv(U.uProj, false, proj);
|
|
617
|
+
gl.uniform2f(U.uFocal, fy, fy);
|
|
618
|
+
gl.uniform2f(U.uViewport, canvas.width, canvas.height);
|
|
619
|
+
gl.uniform3f(U.uCamPos, ...eye);
|
|
620
|
+
gl.bindVertexArray(vao);
|
|
621
|
+
gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, n);
|
|
622
|
+
|
|
623
|
+
stats.textContent = `${n.toLocaleString('ru-RU')} гауссиан · ${Math.round(fps)} FPS`;
|
|
624
|
+
requestAnimationFrame(frame);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ---------- запись видео ----------
|
|
628
|
+
|
|
629
|
+
const btnRec = document.getElementById('btn-rec');
|
|
630
|
+
btnRec.addEventListener('click', () => {
|
|
631
|
+
const mime = ['video/mp4;codecs=avc1', 'video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm']
|
|
632
|
+
.find((m) => window.MediaRecorder && MediaRecorder.isTypeSupported(m));
|
|
633
|
+
if (!mime) { alert('Браузер не поддерживает запись видео (MediaRecorder).'); return; }
|
|
634
|
+
const rec = new MediaRecorder(canvas.captureStream(60), { mimeType: mime, videoBitsPerSecond: 16_000_000 });
|
|
635
|
+
const chunks = [];
|
|
636
|
+
rec.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
|
|
637
|
+
rec.onstop = () => {
|
|
638
|
+
const blob = new Blob(chunks, { type: mime.split(';')[0] });
|
|
639
|
+
const a = document.createElement('a');
|
|
640
|
+
a.href = URL.createObjectURL(blob);
|
|
641
|
+
a.download = `neuroalpha-splat.${mime.startsWith('video/mp4') ? 'mp4' : 'webm'}`;
|
|
642
|
+
a.click();
|
|
643
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
|
644
|
+
btnRec.disabled = false;
|
|
645
|
+
btnRec.className = 'primary';
|
|
646
|
+
btnRec.textContent = `● Записать ${RECORD_SECONDS} сек`;
|
|
647
|
+
};
|
|
648
|
+
timeOrigin = performance.now();
|
|
649
|
+
rec.start();
|
|
650
|
+
btnRec.disabled = true;
|
|
651
|
+
btnRec.className = 'rec';
|
|
652
|
+
let left = RECORD_SECONDS;
|
|
653
|
+
btnRec.textContent = `Запись… ${left}`;
|
|
654
|
+
const timer = setInterval(() => {
|
|
655
|
+
left -= 1;
|
|
656
|
+
if (left > 0) { btnRec.textContent = `Запись… ${left}`; return; }
|
|
657
|
+
clearInterval(timer);
|
|
658
|
+
rec.stop();
|
|
659
|
+
}, 1000);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
document.getElementById('btn-new').addEventListener('click', () => {
|
|
663
|
+
loadScene();
|
|
664
|
+
timeOrigin = performance.now();
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
loadScene();
|
|
668
|
+
requestAnimationFrame(frame);
|
|
669
|
+
</script>
|
|
670
|
+
</body>
|
|
671
|
+
</html>
|