Виртуальный полигон для наземной техники
Author
using Engee, GenieFramework, Sockets
using Genie.Router
# ==================================================================
# Константы
# ==================================================================
const GUI_IN_PORT = 5150 # порт, на который Engee шлёт данные
# ==================================================================
# Глобальное состояние
# ==================================================================
const _udp_started = Ref(false)
const _restart_count = Ref(0)
const _packets_received = Ref(0)
const _packets_processed = Ref(0)
const SOCK_RX = Ref{Any}(nothing)
const UPDATE = Ref{Any}(nothing)
# ---------- reader ----------
const _latest_packet = Ref{Any}(nothing)
function _reader(sock::UDPSocket, name::String)
@info "reader $name: старт"
while isopen(sock)
try
data = recv(sock)
if length(data) == 16
_packets_received[] += 1
_latest_packet[] = data
end
catch e
@warn "reader $name: ошибка" exception=e
sleep(0.1)
end
end
end
function _processor(upd)
@info "processor: старт"
while true
data = _latest_packet[]
if data !== nothing
_latest_packet[] = nothing
try
v1 = ntoh(reinterpret(Float64, data[1:8])[1]) # yaw, градусы
v2 = ntoh(reinterpret(Float64, data[9:16])[1]) # speed, м/с или %
Base.invokelatest(upd, v1, v2)
_packets_processed[] += 1
catch e
@warn "processor: ошибка" exception=e
end
end
sleep(0.02)
end
end
# ---------- управление UDP ----------
function _stop_udp!()
if SOCK_RX[] !== nothing
try; close(SOCK_RX[]); catch; end
SOCK_RX[] = nothing
end
_udp_started[] = false
end
function _start_udp!()
try
SOCK_RX[] = UDPSocket()
bind(SOCK_RX[], ip"127.0.0.1", GUI_IN_PORT)
@info "UDP: bind ok $(GUI_IN_PORT)"
catch e
@error "UDP: bind" exception=e
return
end
@async _reader(SOCK_RX[], "data")
@async _processor(UPDATE[])
_udp_started[] = true
end
function _reset_udp!()
_restart_count[] += 1
@info "reset UDP #$(_restart_count[])"
_stop_udp!()
_start_udp!()
end
# ==================================================================
# HTTP-маршрут для кнопки Reset UDP
# ==================================================================
route("/reset", method = POST) do
@info "HTTP POST /reset"
try
_reset_udp!()
return "ok"
catch e
@error "reset handler" exception=e
return "error"
end
end
# ==================================================================
# OBJ-парсер на Base
# ==================================================================
# Возвращает (positions::Vector{Float32}, indices::Vector{UInt32})
# positions: вершины, indices: треугольники (3 индекса на каждый)
# (для удобства — уже разбитые на треугольники, если в OBJ были n-угольники)
function load_obj(path::String)
verts = Vector{Float32}()
tris = Vector{UInt32}()
for line in eachline(path)
s = strip(line)
isempty(s) && continue
startswith(s, "#") && continue
parts = split(s; keepempty=false)
isempty(parts) && continue
head = parts[1]
if head == "v"
push!(verts, parse(Float32, parts[2]))
push!(verts, parse(Float32, parts[3]))
push!(verts, parse(Float32, parts[4]))
elseif head == "f"
# грани могут быть "f 1 2 3", "f 1/1 2/2 3/3", "f 1/1/1 2/2/2 3/3/3"
idx = Int[]
for tok in parts[2:end]
# берём только первый сегмент до "/"
i = findfirst('/', tok)
numstr = i === nothing ? tok : tok[1:i-1]
push!(idx, parse(Int, numstr))
end
# триангуляция веером: (1,2,3), (1,3,4), (1,4,5), ...
for k in 2:(length(idx)-1)
# OBJ индексирует с 1; → 0-базовый
push!(tris, UInt32(idx[1] - 1))
push!(tris, UInt32(idx[k] - 1))
push!(tris, UInt32(idx[k+1] - 1))
end
end
end
return verts, tris
end
const MODEL_PATH = joinpath(@__DIR__, "vehicle_model_btr_80.obj")
const VERTEX_DATA, TRI_DATA = load_obj(MODEL_PATH)
@info "OBJ загружен: $(length(VERTEX_DATA)/3) вершин, $(length(TRI_DATA)/3) треугольников"
# Один раз пакуем в бинарные блобы для передачи в JS
const VERTEX_JSON = "[" * join(string.(VERTEX_DATA), ",") * "]"
const INDEX_JSON = "[" * join(string.(TRI_DATA), ",") * "]"
# ==================================================================
# Stipple-модель
# ==================================================================
@app begin
# ---- три угла, приходящие в одном векторе (градусы, могут быть < 0) ----
@out angle_yaw = 0.0 # градусы
@out speed = 0.0 # м/с или %
@out cam_signal = 0.0
@out hud_line = "⚪ WAITING"
@out packets_ok = 0
@out packets_bad = 0
@out restarts = 0
@out rx_count = 0
@out tx_count = 0
@out queue_len = 0
@out reset_signal = 0
@out resetui_signal = 0
# ---------- Reset UDP ----------
@onchange reset_signal begin
if reset_signal[] > 0
try
_reset_udp!()
restarts += 1
packets_ok = 0
packets_bad = 0
hud_line = "🟠 RESET #$(restarts[])"
catch e
@error "reset_signal handler" exception=e
end
end
end
# ---------- Reset UI ----------
@onchange resetui_signal begin
if resetui_signal[] > 0
try
Stipple.js_update("window.location.reload()")
catch e
@error "resetui handler" exception=e
end
end
end
# ---------- старт ----------
@onchange isready begin
UPDATE[] = (ya, sp) -> begin
angle_yaw = ya
speed = sp
packets_ok += 1
end
if _udp_started[]
return
end
@info "isready: запускаю UDP"
try
_start_udp!()
@async _processor(UPDATE[])
catch e
@error "isready" exception=e
end
@async begin
while true
ya = angle_yaw[]
sp = speed[]
ok = packets_ok[]
bad = packets_bad[]
rx_count = _packets_received[]
tx_count = _packets_processed[]
queue_len = rx_count - tx_count
flag = ok == 0 ? "⚪" : (ok % 2 == 0 ? "🟢" : "🟩")
hud_line = string(
flag, " pkts:", lpad(ok, 6, ' '), " ✕", lpad(bad, 2, ' '),
" rx:", lpad(rx_count, 6, ' '), " tx:", lpad(tx_count, 6, ' '),
" lost:", lpad(queue_len, 4, ' '),
" θy=", rpad(string(round(ya, digits=1)), 7, ' '),
" v=", rpad(string(round(sp, digits=2)), 7, ' '),
" ⟲", restarts[],
)
sleep(0.2)
end
end
end
end
# ==================================================================
# UI
# ==================================================================
function ui()
[
# ---- HUD + кнопки в одну строку ----
row([
column([
Html.div(
Html.pre(
"{{hud_line}}",
style = "font-family: 'DejaVu Sans Mono', monospace;" *
"font-size: 14px;" *
"background:#111;color:#7cf;" *
"padding:6px 10px;border-radius:4px;" *
"margin:4px 0;white-space:pre;"
)
),
], width=9, align="center"),
Html.button("⟳ Camera",
onclick = "window.__toggleCam && window.__toggleCam();",
style = "font-size:14px;padding:6px 12px;" *
"background:#2a7;color:#fff;border:none;" *
"border-radius:4px;cursor:pointer;margin-right:6px;"
),
Html.button("⟲ Reset UI",
onclick = "window.location.reload();",
style = "font-size:14px;padding:6px 12px;" *
"background:#d33;color:#fff;border:none;" *
"border-radius:4px;cursor:pointer;margin-right:6px;"
),
Html.button("⟲ Reset UDP",
onclick = "fetch('/reset', {method:'POST'})" *
".then(r => r.text())" *
".then(t => console.log('reset:', t));",
style = "font-size:14px;padding:6px 12px;" *
"background:#37a;color:#fff;border:none;" *
"border-radius:4px;cursor:pointer;"
),
], width="100%"),
# ---- скрытые носители значений для JS ----
Html.div("{{angle_yaw}}", id="valYaw", style="display:none"),
Html.div("{{speed}}", id="valSpeed", style="display:none"),
# ---- передача модели в JS как JSON ----
Html.div(VERTEX_JSON, id="modelVerts", style="display:none"),
Html.div(INDEX_JSON, id="modelTris", style="display:none"),
# ---- canvas ----
Html.canvas(id="flightCanvas",
width="900", height="600",
style="display:block;margin:12px auto;" *
"background:#1a1a2e;border:1px solid #444;"),
# ---- WebGL-скрипт (тот же, что был; читает 3 угла из скрытых div-ов) ----
Html.script(raw"""
(function () {
if (window.__flightRunning) return;
window.__flightRunning = true;
console.log('[run] script loaded');
const STATE = {
arrowPos: null, arrowNrm: null, arrowIdx: null, arrowCount: 0,
arrowIdxType: null,
// ---- визуальное состояние (интегрируется локально) ----
modelX: 0,
modelZ: 0,
yaw: 0, // текущий сглаженный yaw (рад)
speed: 0, // текущая сглаженная скорость (м/с)
// ---- "сырые" таргеты из Engee (обновляются ~5 Гц) ----
targetYaw: 0, // рад
targetSpeed: 0, // м/с
// ---- камера ----
camMode: 0,
chaseDist: 4.0,
chaseHeight: 1.5,
camYaw: -0.35,
camPitch: 0.20,
camDist: 8.0,
camDrag: { active:false, x:0, y:0 },
// ---- GL ----
lastTime: 0,
gl: null, canvas: null,
prog: null, loc: null,
groundPos: null, groundNrm: null, groundUV: null,
groundIdx: null, groundCount: 0,
checkerTex: null,
dbgTick: 0
};
window.__test = {
mat4, identity, multiply, translate, rotX, rotY, rotZ, lookAt, perspective,
STATE
};
window.__toggleCam = function() {
STATE.camMode = 1 - STATE.camMode;
console.log('[run] camMode =', STATE.camMode);
};
// ---------- шейдеры ----------
const VS = `
attribute vec3 aPos;
attribute vec3 aNormal;
attribute vec2 aUV;
uniform mat4 uMVP;
uniform mat4 uModel;
varying vec3 vNormal;
varying vec2 vUV;
void main() {
vNormal = mat3(uModel) * aNormal;
vUV = aUV;
gl_Position = uMVP * vec4(aPos, 1.0);
}`;
const FS = `
precision mediump float;
varying vec3 vNormal;
varying vec2 vUV;
uniform vec3 uColor;
uniform float uUseTex;
uniform vec2 uUVOffset;
uniform sampler2D uTex;
void main() {
vec3 n = normalize(vNormal);
vec3 l1 = normalize(vec3( 0.6, 0.8, 0.7));
vec3 l2 = normalize(vec3(-0.4,-0.2,-0.9));
float d = 0.55 + 0.55 * max(dot(n, l1), 0.0)
+ 0.20 * max(dot(n, l2), 0.0);
vec2 uv = vUV + uUVOffset;
vec3 base = mix(uColor, texture2D(uTex, uv).rgb, uUseTex);
gl_FragColor = vec4(base * d, 1.0);
}`;
function compile(gl, type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
throw new Error(gl.getShaderInfoLog(s));
return s;
}
// ---------- модель ----------
function readModelData() {
const vEl = document.getElementById('modelVerts');
const iEl = document.getElementById('modelTris');
if (!vEl || !iEl) return null;
const verts = JSON.parse(vEl.textContent); // [x0,y0,z0,x1,y1,z1,...]
const tris = JSON.parse(iEl.textContent); // [i0,i1,i2,i3,...]
const pos = new Float32Array(verts);
const idx = new Uint32Array(tris);
let minX=Infinity, maxX=-Infinity, minY=Infinity, maxY=-Infinity, minZ=Infinity, maxZ=-Infinity;
for (let i = 0; i < pos.length; i += 3) {
if (pos[i] < minX) minX = pos[i];
if (pos[i] > maxX) maxX = pos[i];
if (pos[i+1] < minY) minY = pos[i+1];
if (pos[i+1] > maxY) maxY = pos[i+1];
if (pos[i+2] < minZ) minZ = pos[i+2];
if (pos[i+2] > maxZ) maxZ = pos[i+2];
}
const cx = (minX+maxX)/2, cy = (minY+maxY)/2, cz = (minZ+maxZ)/2;
const size = Math.max(maxX-minX, maxY-minY, maxZ-minZ);
const scale = 1.6 * 2 / size;
// высота после масштаба — для подъёма над землёй
const modelHeight = (maxY - minY) * scale;
const yOffset = modelHeight / 2; // низ модели окажется на y = 0
// ОДИН проход: центрирование + масштаб + подъём
for (let i = 0; i < pos.length; i += 3) {
pos[i] = (pos[i] - cx) * scale;
pos[i+1] = (pos[i+1] - cy) * scale + yOffset;
pos[i+2] = (pos[i+2] - cz) * scale;
}
// поворот модели вокруг Y: турель смотрит вправо → повернуть на -90°
for (let i = 0; i < pos.length; i += 3) {
const x = pos[i], z = pos[i+2];
pos[i] = z; // было: pos[i] = z;
pos[i+2] = -x; // было: pos[i+2] = -x;
}
// Вычисляем нормали из треугольников (усреднение по граням)
const nrm = new Float32Array(pos.length);
for (let t = 0; t < idx.length; t += 3) {
const a = idx[t]*3, b = idx[t+1]*3, c = idx[t+2]*3;
const ux = pos[b]-pos[a], uy = pos[b+1]-pos[a+1], uz = pos[b+2]-pos[a+2];
const vx = pos[c]-pos[a], vy = pos[c+1]-pos[a+1], vz = pos[c+2]-pos[a+2];
const nx = uy*vz-uz*vy, ny = uz*vx-ux*vz, nz = ux*vy-uy*vx;
const len = Math.hypot(nx,ny,nz) || 1;
// добавляем к каждой вершине треугольника
for (const i of [a,b,c]) {
nrm[i] += nx/len;
nrm[i+1] += ny/len;
nrm[i+2] += nz/len;
}
}
// нормализуем
for (let i = 0; i < nrm.length; i += 3) {
const l = Math.hypot(nrm[i], nrm[i+1], nrm[i+2]) || 1;
nrm[i] /= l; nrm[i+1] /= l; nrm[i+2] /= l;
}
return { pos, nrm, tris };
}
// ---------- земля ----------
function groundData(size, n) {
const half = size / 2, step = size / n;
const pos = [], nrm = [], uv = [], idx = [];
for (let j = 0; j <= n; j++) {
for (let i = 0; i <= n; i++) {
const x = -half + i*step;
const z = -half + j*step;
pos.push(x, 0, z);
nrm.push(0, 1, 0);
// UV зависят от МИРОВОЙ координаты, а не от индекса сегмента
uv.push(x / 5, z / 5); // одна клетка текстуры = 5 мировых единиц
}
}
for (let j = 0; j < n; j++) {
for (let i = 0; i < n; i++) {
const a = j*(n+1)+i, b = a+1, c = a+n+1, d = c+1;
idx.push(a, c, b, b, c, d);
}
}
return { pos:new Float32Array(pos), nrm:new Float32Array(nrm),
uv:new Float32Array(uv), idx:new Uint16Array(idx),
count: idx.length };
}
// ---------- текстура шахмат ----------
function makeCheckerTexture(gl) {
const SIZE = 64, CHECK = 16;
const c1 = [ 90, 40, 10];
const c2 = [255, 140, 30];
const data = new Uint8Array(SIZE*SIZE*4);
for (let y = 0; y < SIZE; y++)
for (let x = 0; x < SIZE; x++) {
const on = ((x/CHECK|0) + (y/CHECK|0)) % 2;
const c = on ? c2 : c1;
const i = (y*SIZE + x)*4;
data[i]=c[0]; data[i+1]=c[1]; data[i+2]=c[2]; data[i+3]=255;
}
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, SIZE, SIZE, 0,
gl.RGBA, gl.UNSIGNED_BYTE, data);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
// текстура: увеличение — резкое (клетки вблизи), уменьшение — мипмапы
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.generateMipmap(gl.TEXTURE_2D);
const aniso = gl.getExtension('EXT_texture_filter_anisotropic')
|| gl.getExtension('WEBKIT_EXT_texture_filter_anisotropic')
|| gl.getExtension('MOZ_EXT_texture_filter_anisotropic');
if (aniso) {
const maxA = gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT);
gl.texParameterf(gl.TEXTURE_2D,
aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(16, maxA));
}
return tex;
}
// ---------- матрицы ----------
function mat4() { return new Float32Array(16); }
function identity(m) { m.fill(0); m[0]=m[5]=m[10]=m[15]=1; return m; }
function multiply(out, a, b) {
if (out === a || out === b) {
const tmp = new Float32Array(16);
multiplyInto(tmp, a, b);
out.set(tmp);
return out;
}
return multiplyInto(out, a, b);
}
function multiplyInto(out, a, b) {
for (let i = 0; i < 4; i++) for (let j = 0; j < 4; j++) {
let s = 0;
for (let k = 0; k < 4; k++) s += a[k*4+j] * b[i*4+k];
out[i*4+j] = s;
}
return out;
}
function lookAt(out, camPos, target, up) {
let fX = target[0] - camPos[0];
let fY = target[1] - camPos[1];
let fZ = target[2] - camPos[2];
const fl = Math.hypot(fX, fY, fZ) || 1;
fX /= fl; fY /= fl; fZ /= fl;
let sX = fY * up[2] - fZ * up[1];
let sY = fZ * up[0] - fX * up[2];
let sZ = fX * up[1] - fY * up[0];
const sl = Math.hypot(sX, sY, sZ) || 1;
sX /= sl; sY /= sl; sZ /= sl;
const uX = sY * fZ - sZ * fY;
const uY = sZ * fX - sX * fZ;
const uZ = sX * fY - sY * fX;
// right, up, -forward как СТРОКИ, в column-major
out[0] = sX; out[4] = sY; out[8] = sZ; out[12] = 0;
out[1] = uX; out[5] = uY; out[9] = uZ; out[13] = 0;
out[2] = -fX; out[6] = -fY; out[10] = -fZ; out[14] = 0;
out[3] = 0; out[7] = 0; out[11] = 0; out[15] = 1;
return out;
}
function perspective(out, fovy, aspect, near, far) {
const f = 1/Math.tan(fovy/2), nf = 1/(near - far);
out.fill(0);
out[0]=f/aspect; out[5]=f; out[10]=(far+near)*nf;
out[11]=-1; out[14]=2*far*near*nf;
return out;
}
function rotX(out, a) {
const c=Math.cos(a), s=Math.sin(a);
identity(out); out[5]=c; out[6]=s; out[9]=-s; out[10]=c;
return out;
}
function rotY(out, a) {
const c=Math.cos(a), s=Math.sin(a);
identity(out); out[0]=c; out[2]=-s; out[8]=s; out[10]=c;
return out;
}
function rotZ(out, a) {
const c=Math.cos(a), s=Math.sin(a);
identity(out);
out[0]=c; out[1]=s;
out[4]=-s; out[5]=c;
return out;
}
function shortestAngle(a, b) {
let d = b - a;
while (d > Math.PI) d -= 2*Math.PI;
while (d < -Math.PI) d += 2*Math.PI;
return d;
}
function translate(out, x, y, z) {
identity(out); out[12]=x; out[13]=y; out[14]=z;
return out;
}
function makeBuf(gl, data, target) {
const b = gl.createBuffer();
gl.bindBuffer(target, b);
gl.bufferData(target, data, gl.STATIC_DRAW);
return b;
}
// ---------- init GL ----------
function initGL() {
const canvas = document.getElementById('flightCanvas');
if (!canvas) return false;
if (STATE.canvas === canvas && STATE.gl && !STATE.gl.isContextLost())
return true;
const gl = canvas.getContext('webgl', {antialias:true, alpha:false})
|| canvas.getContext('experimental-webgl');
if (!gl) { console.error('[run] WebGL недоступен'); return false; }
STATE.canvas = canvas;
STATE.gl = gl;
const prog = gl.createProgram();
gl.attachShader(prog, compile(gl, gl.VERTEX_SHADER, VS));
gl.attachShader(prog, compile(gl, gl.FRAGMENT_SHADER, FS));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS))
throw new Error(gl.getProgramInfoLog(prog));
gl.useProgram(prog);
STATE.prog = prog;
STATE.loc = {
aPos: gl.getAttribLocation(prog, 'aPos'),
aNormal: gl.getAttribLocation(prog, 'aNormal'),
aUV: gl.getAttribLocation(prog, 'aUV'),
uMVP: gl.getUniformLocation(prog, 'uMVP'),
uModel: gl.getUniformLocation(prog, 'uModel'),
uColor: gl.getUniformLocation(prog, 'uColor'),
uUseTex: gl.getUniformLocation(prog, 'uUseTex'),
uUVOffset: gl.getUniformLocation(prog, 'uUVOffset'),
uTex: gl.getUniformLocation(prog, 'uTex'),
};
const md = readModelData();
if (!md) { console.error('[model] no data'); return false; }
const numVerts = md.pos.length / 3;
// выбираем тип индексов
let idxArr, idxType;
const uintExt = gl.getExtension('OES_element_index_uint');
if (numVerts < 65536 || !uintExt) {
if (numVerts >= 65536) {
console.warn('[model] много вершин, но нет OES_element_index_uint — часть геометрии будет отброшена');
}
idxArr = new Uint16Array(md.tris);
idxType = gl.UNSIGNED_SHORT;
} else {
idxArr = new Uint32Array(md.tris);
idxType = gl.UNSIGNED_INT;
}
STATE.arrowPos = makeBuf(gl, md.pos, gl.ARRAY_BUFFER);
STATE.arrowNrm = makeBuf(gl, md.nrm, gl.ARRAY_BUFFER);
STATE.arrowIdx = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, STATE.arrowIdx);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, idxArr, gl.STATIC_DRAW);
STATE.arrowCount = md.tris.length;
STATE.arrowIdxType = idxType;
const ground = groundData(1000, 200);
STATE.groundPos = makeBuf(gl, ground.pos, gl.ARRAY_BUFFER);
STATE.groundNrm = makeBuf(gl, ground.nrm, gl.ARRAY_BUFFER);
STATE.groundUV = makeBuf(gl, ground.uv, gl.ARRAY_BUFFER);
STATE.groundIdx = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, STATE.groundIdx);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, ground.idx, gl.STATIC_DRAW);
STATE.groundCount = ground.count;
STATE.checkerTex = makeCheckerTexture(gl);
gl.enable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
canvas.addEventListener('webglcontextlost', e => {
e.preventDefault(); STATE.gl = null;
});
canvas.addEventListener('webglcontextrestored', () => {
STATE.gl = null;
});
console.log('[run] GL init ok');
return true;
}
// ---------- реактивные углы ----------
function readReactive() {
const yEl = document.getElementById('valYaw');
const sEl = document.getElementById('valSpeed');
if (yEl) {
const v = parseFloat(yEl.textContent);
// ЕДИНИЦЫ: если Engee шлёт градусы — переводим в радианы ОДИН раз здесь.
if (!isNaN(v)) STATE.targetYaw = v * Math.PI / 180;
}
if (sEl) {
const v = parseFloat(sEl.textContent);
if (!isNaN(v)) STATE.targetSpeed = v;
}
}
function attachObservers() {
const yEl = document.getElementById('valYaw');
const sEl = document.getElementById('valSpeed');
if (yEl && window.MutationObserver)
new MutationObserver(readReactive).observe(yEl,
{childList:true, characterData:true, subtree:true});
if (sEl && window.MutationObserver)
new MutationObserver(readReactive).observe(sEl,
{childList:true, characterData:true, subtree:true});
readReactive();
}
// ---------- отрисовка ----------
const mModel=mat4(), mView=mat4(), mProj=mat4();
const mVP=mat4(), mMVP=mat4();
const mTmp=mat4(), mTmp2=mat4();
function setAttribs(gl, posBuf, nrmBuf, uvBuf) {
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
gl.enableVertexAttribArray(STATE.loc.aPos);
gl.vertexAttribPointer(STATE.loc.aPos, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, nrmBuf);
gl.enableVertexAttribArray(STATE.loc.aNormal);
gl.vertexAttribPointer(STATE.loc.aNormal, 3, gl.FLOAT, false, 0, 0);
if (uvBuf) {
gl.bindBuffer(gl.ARRAY_BUFFER, uvBuf);
gl.enableVertexAttribArray(STATE.loc.aUV);
gl.vertexAttribPointer(STATE.loc.aUV, 2, gl.FLOAT, false, 0, 0);
} else {
gl.disableVertexAttribArray(STATE.loc.aUV);
gl.vertexAttrib2f(STATE.loc.aUV, 0, 0);
}
}
function render() {
try {
const now = performance.now() * 0.001;
const dt = STATE.lastTime > 0 ? (now - STATE.lastTime) : 0.016;
STATE.lastTime = now;
if (!initGL()) { requestAnimationFrame(render); return; }
const gl = STATE.gl, canvas = STATE.canvas;
if (!gl || !canvas || gl.isContextLost()) {
requestAnimationFrame(render); return;
}
// ---------- 1. читаем "сырые" значения из Engee ----------
readReactive();
// ---------- 2. визуальное сглаживание (только для отображения!) ----------
// Engee присылает yaw и speed ~5 Гц; render идёт ~60 Гц.
// Сглаживаем к targetYaw/targetSpeed, чтобы не дёргалось.
const tau = 0.25; // постоянная времени, с
const alpha = 1.0 - Math.exp(-dt / tau);
// кратчайший угол: yaw может "перескочить" через ±180°
STATE.yaw += shortestAngle(STATE.yaw, STATE.targetYaw) * alpha;
STATE.speed += (STATE.targetSpeed - STATE.speed) * alpha;
// ---------- 3. кинематика для рендера ----------
// yaw уже сглажен; интегрируем позицию локально, чтобы модель ехала.
// x/z не приходят из Engee — это чисто визуальная интеграция.
// При рестарте UI позиция сбросится в 0 — это допустимо.
STATE.modelX += -Math.sin(STATE.yaw) * STATE.speed * dt;
STATE.modelZ += -Math.cos(STATE.yaw) * STATE.speed * dt;
// ---------- 4. отрисовка ----------
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0.10, 0.12, 0.22, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
const aspect = canvas.width / canvas.height;
perspective(mProj, Math.PI / 4, aspect, 0.1, 2000);
// ---------- камера ----------
const pivotX = STATE.modelX;
const pivotY = 0.5;
const pivotZ = STATE.modelZ;
let camPosX, camPosY, camPosZ;
if (STATE.camMode === 0) {
const az = STATE.camPitch;
const el = -STATE.camYaw;
const d = STATE.camDist;
camPosX = pivotX + d * Math.sin(az) * Math.cos(el);
camPosY = pivotY + d * Math.sin(el);
camPosZ = pivotZ + d * Math.cos(az) * Math.cos(el);
} else {
const y = STATE.yaw;
const d = STATE.chaseDist;
const h = STATE.chaseHeight;
const fX = -Math.sin(y);
const fZ = -Math.cos(y);
camPosX = pivotX - fX * d;
camPosY = pivotY + h;
camPosZ = pivotZ - fZ * d;
}
const mR = mat4();
lookAt(mR, [camPosX, camPosY, camPosZ], [pivotX, pivotY, pivotZ], [0, 1, 0]);
const mT = mat4();
translate(mT, -camPosX, -camPosY, -camPosZ);
multiply(mView, mR, mT);
multiply(mVP, mProj, mView);
// ---------- земля: всегда под моделью ----------
translate(mModel, STATE.modelX, -0.01, STATE.modelZ);
multiply(mMVP, mVP, mModel);
const uvU = (STATE.modelX / 5) % 1.0;
const uvV = (STATE.modelZ / 5) % 1.0;
gl.uniformMatrix4fv(STATE.loc.uMVP, false, mMVP);
gl.uniformMatrix4fv(STATE.loc.uModel, false, mModel);
gl.uniform3f(STATE.loc.uColor, 1.0, 1.0, 1.0);
gl.uniform1f(STATE.loc.uUseTex, 1.0);
gl.uniform2f(STATE.loc.uUVOffset, uvU, uvV);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, STATE.checkerTex);
gl.uniform1i(STATE.loc.uTex, 0);
setAttribs(gl, STATE.groundPos, STATE.groundNrm, STATE.groundUV);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, STATE.groundIdx);
gl.drawElements(gl.TRIANGLES, STATE.groundCount, gl.UNSIGNED_SHORT, 0);
// ---------- модель ----------
rotY(mTmp, STATE.yaw);
translate(mTmp2, STATE.modelX, 0, STATE.modelZ);
multiply(mModel, mTmp2, mTmp); // T · Ry
multiply(mMVP, mVP, mModel);
gl.uniformMatrix4fv(STATE.loc.uMVP, false, mMVP);
gl.uniformMatrix4fv(STATE.loc.uModel, false, mModel);
gl.uniform3f(STATE.loc.uColor, 0.95, 0.95, 1.0);
gl.uniform1f(STATE.loc.uUseTex, 0.0);
gl.uniform2f(STATE.loc.uUVOffset, 0.0, 0.0);
setAttribs(gl, STATE.arrowPos, STATE.arrowNrm, null);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, STATE.arrowIdx);
gl.drawElements(gl.TRIANGLES, STATE.arrowCount, STATE.arrowIdxType, 0);
// ---------- отладка ----------
STATE.dbgTick++;
if (STATE.dbgTick % 120 === 0) {
console.log('[run]',
'camMode=', STATE.camMode,
'yaw=', STATE.yaw.toFixed(3),
'speed=', STATE.speed.toFixed(2),
'pos=', STATE.modelX.toFixed(2), STATE.modelZ.toFixed(2));
}
} catch (e) {
console.error('[run] render error', e);
}
requestAnimationFrame(render);
}
// ---------- обработчики на window ----------
function isCanvasTarget(e) {
const c = document.getElementById('flightCanvas');
return c && e.target === c;
}
function attachInputHandlers() {
window.addEventListener('mousedown', e => {
if (!isCanvasTarget(e)) return;
STATE.camDrag.active = true;
STATE.camDrag.x = e.clientX;
STATE.camDrag.y = e.clientY;
});
window.addEventListener('mouseup', () => STATE.camDrag.active = false);
window.addEventListener('mousemove', e => {
if (!STATE.camDrag.active) return;
const dx = e.clientX - STATE.camDrag.x;
const dy = e.clientY - STATE.camDrag.y;
STATE.camDrag.x = e.clientX;
STATE.camDrag.y = e.clientY;
if (STATE.camMode !== 0) return; // ← новая строка
STATE.camPitch += dx * 0.01;
STATE.camYaw -= dy * 0.01;
STATE.camYaw = Math.max(-Math.PI/3, Math.min(Math.PI/3, STATE.camYaw));
});
window.addEventListener('touchstart', e => {
if (!isCanvasTarget(e)) return;
if (e.touches.length === 1) {
STATE.camDrag.active = true;
STATE.camDrag.x = e.touches[0].clientX;
STATE.camDrag.y = e.touches[0].clientY;
}
}, {passive:true});
window.addEventListener('touchend', () => STATE.camDrag.active = false);
window.addEventListener('touchmove', e => {
if (!STATE.camDrag.active || e.touches.length !== 1) return;
const dx = e.touches[0].clientX - STATE.camDrag.x;
const dy = e.touches[0].clientY - STATE.camDrag.y;
STATE.camDrag.x = e.touches[0].clientX;
STATE.camDrag.y = e.touches[0].clientY;
STATE.camPitch += dx * 0.01;
STATE.camYaw += dy * 0.01;
STATE.camYaw = Math.max(-Math.PI/2 + 0.05,
Math.min( Math.PI/2 - 0.05, STATE.camYaw));
}, {passive:true});
window.addEventListener('wheel', e => {
if (!isCanvasTarget(e)) return;
e.preventDefault();
if (STATE.camMode === 0) {
STATE.camDist = Math.max(1.5, Math.min(200.0, STATE.camDist + e.deltaY * 0.005));
} else {
STATE.chaseDist = Math.max(1.5, Math.min(200.0, STATE.chaseDist + e.deltaY * 0.005));
}
}, {passive:false});
window.addEventListener('dblclick', e => {
if (!isCanvasTarget(e)) return;
STATE.camYaw = -0.35;
STATE.camPitch = 0.20;
STATE.camDist = 8.0;
});
}
function bootstrap() {
const canvas = document.getElementById('flightCanvas');
if (!canvas) { setTimeout(bootstrap, 100); return; }
console.log('[run] canvas found');
initGL();
attachObservers();
attachInputHandlers();
requestAnimationFrame(render);
}
bootstrap();
})();
""")
]
end
@page("/", ui)