diff --git a/docs/plan.md b/docs/plan.md index 26f6c23..729b1ba 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -46,6 +46,7 @@ | 24 | v2 трек 3: генераторы воксельных моделей — формат внутреннего JSON (size + dense base64, валидация агентом; .vox — позже), процедурные генераторы (деревья 3 варианта, валуны 3 варианта, детерминизм по сиду), штамповка в мир с клиппингом, демка на генераторах, гейт: варианты различаются, штампы на месте | ✅ (2026-09-09). `math/rng.ts` (mulberry32), `models/format.ts` (encode/decode/validate), `models/generators.ts` (деревья: круглая/колонна/ель; валуны: union сфер + свет на верхних гранях), `models/stamp.ts` (клиппинг half-open). Демка: 12 деревьев + 3 валуна из генераторов, ручные tree/boulder удалены (урок №4). Гейт 12 проверок: + модельная статистика, + детерминизм через мост (`stampTree` дважды → тот же счётчик, вариант 1 ≠ вариант 0). Грабля: `VoxelGrid.count()` — метод, а не геттер (тесты дважды ловили). | | 25 | v2 трек 4: turntable-листы — визуальный гейт генераторов: страница со всеми вариантами моделей (деревья/валуны) × 4 поворота, скриншот в /tmp, проверки: ячейки непустые, варианты попарно различаются; основа для апрува сгенерированного контента агентом | ✅ (2026-09-09). `apps/demo/turntable.html` + `src/turntable.ts`: 6 моделей × 4 ракурса (0/45/90/135° — диагонали дают другой силуэт) на подставках 8×8×2, ракурс — `mesh.rotation.y` вокруг центра (geometry.translate −sx/2). Гейт `tools/checks/turntable.mjs`: центры ячеек считаются той же проекцией, что камера (зеркало lookAt-базиса), референсный кадр без моделей через `setModels(false)`, выборка — эллипс 48×42 в ромбе подставки; 5/5 зелёных, minDiff 570/490 при пороге 150. `launchBrowser({viewport})` в agent-lib (страница 720px выше дефолтного вьюпорта). Уроки: дробные координаты VoxelGrid.set молчу no-op (Uint8Array[4.5]); скриншот = вьюпорт браузера, канвас без центрирования; малые боксы у края ромба дают мало пикселей — выборка эллипсом. | | 26 | v2 трек 5: .vox импорт/экспорт (MagicaVoxel) — parseVox/exportVox (чанки MAIN/SIZE/XYZI/RGBA, PACK>1 — ошибка), оси vox z-up → движок y-up, слоты модели → подряд 1..N при экспорте; тесты roundtrip; мост демки `stampVoxB64` + гейт: штамп .vox меняет кадр | ✅ (2026-09-09). `models/vox.ts`: полный обход чанков (неизвестные пропускаются с детьми, обрыв файла и обрыв в заголовке — ошибки), оси (x,y,z)_engine = (x,z,y)_vox, RGBA ровно 256 записей. Обнаружили «слоты модели ≠ слоты сцены» → `models/paletteMap.ts`: `remapSlots` (точный hex или ближайший RGB, детерминированно). Демка: мост `stampVoxB64(b64,x,z)` (парсинг → перекладка на палитру сцены → штамп + ремешинг). Гейт 13 проверок: `.vox`-фикстура (собрана exportVox-ом, base64 вкладывается в скрипт) штампует 12 вокселей и меняет кадр. Уроки: **Node не импортирует `@rpg/engine`** (TS-исходники с extensionless-импортами) — фиксстуры для гейтов генерятся tsx-ом и вкладываются в скрипт готовыми; точка штампа для проверки «кадр изменился» обязана быть в кадре камеры (штамп вне вью дал placed=12 при voxDiff=0); заголовок чанка — 12 байт (id+contentSize+childrenSize), обрыв в нём ловить границей в `id()`, иначе голый RangeError из DataView. | +| 27 | v2 трек 6: рига/анимация («анимация = данные») — рига: кости (имя, родитель, pivot в связочном пространстве), детерминированная привязка вокселей к ближайшей кости; клипы: ключевые кадры {t, кость: rot/pos}, линейный семплинг поз, числовая валидация (монотонность ключей, непрерывность цикла первый=последний, контакт стопы); процедурная локомоция (walk-цикл из параметров); генератор манекена с ригой; рендер деформированных вокселей (instanced-облако); превью-лист кадров + мост (числовые снапшоты поз) + гейт | ✅ (2026-09-09). `animation/mat.ts` (чистая аффинная математика, порядок Эйлера X→Y→Z), `animation/rig.ts` (кости + pivot в связочном пространстве; привязка: у генератора — явная, для чужих — `bindNearest` по pivot; `poseVoxels` — жёсткая деформация на кость), `animation/clip.ts` (ключи {t, кость: rot/pos}, линейный семплинг с заворотом t для циклов, `validateClip` — границы/монотонность/замкнутость цикла, `footMinY` — контакт стопы), `animation/locomotion.ts` (walkClip: ноги в противофазе, руки против одноимённой ноги, корень приседает дважды и качается), `models/mannequin.ts` (5×11×3, 5 костей, явная привязка — Voronoi по pivot отнёс бы торс к рукам). Рендер: `addVoxelCloud` (InstancedMesh, ось-выровненные кубы, обновление позы без пересборки геометрии). Демка: `anim.html` + `anim.ts` — 8 кадров цикла (4×2), мост `poseStats(i)`/`clipCheck()`/`setModels`. Гейт `anim.mjs` 8 проверок: картинка (ячейки видны, соседние фазы различаются) + числа через мост (клип замкнут, противофазы, контакт стоп ≈0.5 в опоре / подъём в махе, приседание и качание корня). 91 юнит-тест. Уроки: **composition affine — перенос parent добавлялся дважды** (тест иерархии поймал: ребёнок уезжал на 2·t вместо t); **binding индексируется по сетке, posed-массив только из занятых вокселей** — сверять через `index` вокселя, не позицией в массиве; **Voronoi-привязка по pivot семантически неверна** для внутренних вокселей (торс у плеч ближе к кости руки) — генератор привязывает явно; соседние фазы 45° дают ~110 px разницы на мелком манекене (порог гейта 50, не 150); sheetInfo/gейт должен отдавать РЕАЛЬНЫЙ viewSize камеры (в turntable 30 vs 32 проходило только за счёт запаса эллипса — исправлено). | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 41dcf3c..77baee1 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -57,6 +57,8 @@ `world.get`, шов чанков бесшовный (culled и AO через границы); - `src/render/voxelRenderer.ts` — three.js-бэкенд: ортокамера диметрии 2:1, dirLight + shadow map + hemisphere, низкое разрешение (480×270); + `addVoxelCloud` — instanced-кубы для поз анимации (не выровнены по + сетке; обновление позы без пересборки геометрии); - `src/math/rng.ts` — детерминированный Rng (mulberry32): next/int/range/chance/pick; - `src/models/format.ts` — формат модели (JSON: size + плотный base64 + слоты-палитра), encode/decode/validate (числовая валидация агентом); @@ -72,6 +74,19 @@ - `src/models/paletteMap.ts` — `remapSlots(model, palette)`: перекладка слотов модели на палитру сцены (точный hex или ближайший RGB, при равенстве — меньший слот); нужен после импорта .vox; + - `src/models/mannequin.ts` — `mannequin()`: эталонная модель 5×11×3 + с ригой (root → legL/legR/armL/armR, pivot в связочном пространстве); + - `src/animation/mat.ts` — аффинная математика без three (Vec3/Mat3/Affine, + порядок Эйлера X→Y→Z, rotateAround); чистая, тестируется без рендера; + - `src/animation/rig.ts` — рига: `makeRig` (привязка явная или `bindNearest` + по ближайшему pivot), `validateRig` (родители раньше детей, длина и + согласованность привязки), `poseVoxels(rig, model, pose)` — деформация; + - `src/animation/clip.ts` — клипы: ключи {t, кость: rot/pos}, `samplePose` + (линейный семплинг, заворот t для циклов), `validateClip` (границы, + монотонность, замкнутость цикла), `footMinY` (контакт стопы); + - `src/animation/locomotion.ts` — `walkClip(params)`: процедурный walk-цикл + (ноги в противофазе, руки против одноимённой ноги, корень приседает + дважды за цикл и качается вбок); детерминирован, проходит валидацию; - `tools/agent-lib.mjs` — браузерный каркас проверок (launchBrowser с опциональным viewport/ startDevServer/openGame/AgentClient/Checks), swiftshader в headless; @@ -86,11 +101,21 @@ Мост демки: `window.__agent.setSun(k)`/`stats()`/`setVoxel(x,y,z,c)`/`chunkStats()`/`modelStats()`/`stampTree(v,x,z,seed)`/`stampVoxB64(b64,x,z)` (парсинг → `remapSlots` на палитру сцены → штамп)/`perf()`. - `apps/demo/turntable.html` + `src/turntable.ts` — turntable-лист: все варианты генераторов (деревья/валуны) × 4 ракурса на подставках 8×8×2; мост - `sheetInfo()` (геометрия листа для расчёта ячеек) и `setModels(on)` (скрыть - модели для референсного кадра). Канвас в левом верхнем углу — пиксели + `sheetInfo()` (геометрия листа для расчёта ячеек — viewSize как у камеры!) + и `setModels(on)` (скрыть модели для референсного кадра). Канвас в левом + верхнем углу — пиксели скриншота считаются от (0,0). Гейт `tools/checks/turntable.mjs`: проекция ячеек той же матрицей, что камера, эллипс 48×42 в ромбе подставки, 5 проверок (ячейки непустые, варианты попарно различаются). +- `apps/demo/anim.html` + `src/anim.ts` — аним-лист: 8 кадров walk-цикла + манекена (4×2, фазы i/8) на подставках; клип — `walkClip`, позы — + `samplePose`→`poseVoxels`, рисуются `addVoxelCloud`. Мост: `sheetInfo()`, + `poseStats(i)` (числовой снапшот позы: углы, качание корня, `footMinY` + обеих стоп), `clipCheck()` (validateClip через мост), `setModels(on)`. + Гейт `tools/checks/anim.mjs`: 8 проверок — картинка (ячейки видны, + соседние фазы различаются, порог 50) + числа через мост (клип замкнут, + противофазы ног/рук, контакт стоп в i=0/i=4, приседание и качание корня). + Фазы 0 и 4 двухшагового цикла совпадают — это норма, не баг. ## Известные грабли (уже собранные) @@ -116,6 +141,17 @@ - **Заголовок .vox-чанка — 12 байт** (id + contentSize + childrenSize); обрыв в заголовке ловить границей в `id()`, иначе голый RangeError из DataView вместо понятной ошибки. +- **Привязка вокселей индексируется по сетке, а posed-массив — только + занятые воксели**: сопоставлять через `PosedVoxel.index`, не позицией + в массиве (footMinY из-за этого дважды бы соврал). +- **Voronoi-привязка по pivot семантически неверна** для внутренних + вокселей: торс у плеча ближе к кости руки, чем к корню. Генератор + привязывает части явно; `bindNearest` — только для чужих моделей. +- **sheetInfo/мост должен отдавать РЕАЛЬНЫЙ viewSize камеры**: в turntable + стояло 30 при камере 32 — гейт проходил только за счёт запаса эллипса. +- **Соседние фазы walk-цикла (45°) на мелком манекене дают ~110 px + разницы** — порог гейта «фазы различаются» 50, не 150 как у вариантов + моделей; а фазы 0 и 4 совпадают полностью (симметрия двух шагов). ## Рабочие привычки diff --git a/v2/apps/demo/anim.html b/v2/apps/demo/anim.html new file mode 100644 index 0000000..0ccfd03 --- /dev/null +++ b/v2/apps/demo/anim.html @@ -0,0 +1,22 @@ + + + + + Аним-лист v2 — кадры walk-цикла + + + + + + + \ No newline at end of file diff --git a/v2/apps/demo/src/anim.ts b/v2/apps/demo/src/anim.ts new file mode 100644 index 0000000..f8dd12e --- /dev/null +++ b/v2/apps/demo/src/anim.ts @@ -0,0 +1,124 @@ +/** + * Аним-лист: 8 кадров walk-цикла манекена (4×2) на подставках. + * Превью-лист кадров анимации (docs/plan.md, трек 6): клип процедурный + * (walkClip), позы считаются samplePose→poseVoxels, рисуются instanced-облаком. + * Мост отдаёт геометрию листа и числовые снапшоты поз — гейт проверяет + * и картинку, и данные (контакт стопы, противофазы, замкнутость цикла). + */ +import { + VoxelRenderer, VoxelGrid, meshVoxels, mannequin, walkClip, samplePose, poseVoxels, + validateClip, footMinY, +} from '@rpg/engine'; +import type { VoxelPalette, VoxelCloud } from '@rpg/engine'; + +// Палитра арт-библии — та же, что в демке (слоты → hex) +const PALETTE: VoxelPalette = { + colors: { + 1: '#4a5340', 2: '#343b2c', 3: '#626c4c', 4: '#5c3b2e', 5: '#7d8662', + 6: '#5b5b66', 7: '#94949e', 8: '#82503a', 9: '#d99a32', 10: '#44454e', + }, +}; + +const COLS = 4; // кадры 0..3 по оси X +const ROWS = 2; // кадры 4..7 по оси Z +const FRAMES = 8; +const PITCH = 9; + +// walk-цикл манекена: параметры локомоции (агент правит их текстом через клип) +const DURATION = 0.8; +const clip = walkClip({ duration: DURATION, legSwing: 0.55, armSwing: 0.4, bob: 0.25, sway: 0.15 }); +const { model, rig } = mannequin(); + +const sheetW = COLS * PITCH; +const sheetD = ROWS * PITCH; +const target: [number, number, number] = [sheetW / 2, 2, sheetD / 2]; +const VIEW_SIZE = 22; // лист 36×18 + рост манекена — с запасом + +const canvas = document.getElementById('scene') as HTMLCanvasElement; +const renderer = new VoxelRenderer(canvas, { width: 480, height: 360, target, viewSize: VIEW_SIZE }); + +/** Центр ячейки (целый: воксельные координаты должны быть целыми). */ +function cellCenter(col: number, row: number): [number, number] { + return [col * PITCH + Math.floor(PITCH / 2), row * PITCH + Math.floor(PITCH / 2)]; +} + +/** Подставки ячеек: одна сетка на весь лист, плита 8×8×2 в центре каждой. */ +function buildPads(): VoxelGrid { + const grid = new VoxelGrid(sheetW, 2, sheetD); + for (let col = 0; col < COLS; col++) + for (let row = 0; row < ROWS; row++) { + const [cx, cz] = cellCenter(col, row); + for (let dx = -4; dx < 4; dx++) + for (let dz = -4; dz < 4; dz++) { + grid.set(cx + dx, 0, cz + dz, 2); + const patch = ((cx + dx) * 7 + (cz + dz) * 13) % 11 < 3; + grid.set(cx + dx, 1, cz + dz, patch ? 3 : 1); + } + } + return grid; +} +renderer.addMesh(meshVoxels(buildPads(), PALETTE)); + +// Кадры цикла: поза i → instanced-облако в ячейке (row = i / COLS) +const clouds: VoxelCloud[] = []; +for (let i = 0; i < FRAMES; i++) { + const col = i % COLS, row = Math.floor(i / COLS); + const [cx, cz] = cellCenter(col, row); + const pose = samplePose(clip, (DURATION * i) / FRAMES); + const posed = poseVoxels(rig, model, pose).map((v) => ({ + // манекен 5×11×3 — центр по XZ ячейки, на подставке (y=2) + pos: [v.pos[0] + cx - model.size[0] / 2, v.pos[1] + 2, v.pos[2] + cz - model.size[2] / 2] as [number, number, number], + color: v.color, + })); + clouds.push(renderer.addVoxelCloud(posed)); +} + +function stats(): { clouds: number; voxels: number } { + return { clouds: clouds.length, voxels: clouds.reduce((n, c) => n + c.mesh.count, 0) }; +} + +/** Геометрия листа для расчёта ячеек в гейте (та же проекция, что у камеры). */ +function sheetInfo() { + return { + cols: COLS, rows: ROWS, pitch: PITCH, frames: FRAMES, duration: DURATION, + target, viewSize: VIEW_SIZE, width: 480, height: 360, scale: 2, + }; +} + +/** Числовый снапшот позы кадра i: углы, качание корня, контакт стоп. */ +function poseStats(i: number) { + const t = (DURATION * i) / FRAMES; + const pose = samplePose(clip, t); + return { + t, + legL: pose.legL?.rot?.[0] ?? 0, + legR: pose.legR?.rot?.[0] ?? 0, + armL: pose.armL?.rot?.[0] ?? 0, + armR: pose.armR?.rot?.[0] ?? 0, + rootPos: pose.root?.pos ?? [0, 0, 0], + footMinL: footMinY(rig, model, clip, 'legL', t), + footMinR: footMinY(rig, model, clip, 'legR', t), + }; +} + +const agent = { + stats, + sheetInfo, + poseStats, + /** Числовая валидация клипа (замкнутость цикла) — через мост, как валидация агентом. */ + clipCheck(): string[] { + return validateClip(clip); + }, + /** Скрыть/показать кадры (подставки остаются) — референсный кадр гейта. */ + setModels(on: boolean): void { + for (const c of clouds) c.mesh.visible = on; + }, +}; +(window as unknown as Record).__agent = agent; + +function frame(): void { + renderer.render(); + requestAnimationFrame(frame); +} +frame(); +(window as unknown as Record).__beacon = true; \ No newline at end of file diff --git a/v2/apps/demo/src/turntable.ts b/v2/apps/demo/src/turntable.ts index 8eff55d..87fac2a 100644 --- a/v2/apps/demo/src/turntable.ts +++ b/v2/apps/demo/src/turntable.ts @@ -88,7 +88,7 @@ function sheetInfo() { return { cols: COLS, rows: ROWS, pitch: PITCH, - target, viewSize: 30, width: 480, height: 360, scale: 2, + target, viewSize: 32, width: 480, height: 360, scale: 2, models: MODELS.map((m) => m.name), }; } diff --git a/v2/apps/demo/tools/checks/anim.mjs b/v2/apps/demo/tools/checks/anim.mjs new file mode 100644 index 0000000..4ccb9ae --- /dev/null +++ b/v2/apps/demo/tools/checks/anim.mjs @@ -0,0 +1,140 @@ +/** + * Гейт аним-листа: превью walk-цикла манекена (8 кадров, 4×2) — визуальная + * и числовая проверка анимации (docs/plan.md, трек 6, «анимация = данные»). + * Картинка: ячейки видны, соседние фазы различаются (та же проекция, что + * камера, референсный кадр без моделей). Числа через мост: клип замкнут + * (validateClip), противофазы ног/рук, контакт стоп, качание корня. + * Скриншоты: /tmp/v2_anim.png, /tmp/v2_anim_pads.png. + * + * Запуск: node apps/demo/tools/checks/anim.mjs [--port 5299] + */ +import { readFileSync } from 'node:fs'; +import { sleep, launchBrowser, startDevServer, openGame, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; +import { decodePng } from '@rpg/engine/tools/png.mjs'; + +const args = parseArgs(process.argv.slice(2)); +const port = Number(args.port ?? 5299); +const server = startDevServer({ port, cwd: new URL('../../', import.meta.url).pathname }); + +const report = { name: 'anim', ok: false, checks: [] }; +const check = (title, ok, info = null) => { + report.checks.push({ title, ok, info }); + console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`); +}; + +try { + await server.waitReady; + // страница 960×720 — вьюпорт с запасом, иначе низ листа обрезается кадром + const browser = await launchBrowser({ viewport: { width: 960, height: 760 } }); + try { + const client = await openGame(browser, { url: `http://localhost:${port}/anim.html` }); + + const errs = client.errors(); + check('нет pageerror при загрузке листа', errs.length === 0, errs.slice(0, 3)); + + const info = await client.call('sheetInfo'); + const stats = await client.call('stats'); + check('лист: 8 кадров манекена в облаках', stats.clouds === 8 && stats.voxels > 100, stats); + + // Числовая валидация клипа через мост (как агент валидирует текстом) + const problems = await client.call('clipCheck'); + check('клип замкнут и проходит валидацию', problems.length === 0, problems); + + const full = decodePng(readFileSync(await client.screenshot('/tmp/v2_anim.png'))); + await client.call('setModels', false); + await sleep(300); + const pads = decodePng(readFileSync(await client.screenshot('/tmp/v2_anim_pads.png'))); + await client.call('setModels', true); + + // Ячейки: проекция мирового центра ячейки той же матрицей, что камера + const cells = []; + for (let col = 0; col < info.cols; col++) + for (let row = 0; row < info.rows; row++) { + const wx = col * info.pitch + Math.floor(info.pitch / 2); + const wz = row * info.pitch + Math.floor(info.pitch / 2); + const [px, py] = worldToPx([wx, 3, wz], info); + cells.push({ col, row, px, py, diff: cellDiff(full, pads, px, py) }); + } + const empty = cells.filter((c) => c.diff <= 150); + check('во всех 8 ячейках манекен виден', empty.length === 0, + empty.length ? empty : { minDiff: Math.min(...cells.map((c) => c.diff)) }); + + // Соседние фазы различаются (внутри ряда; шаг фазы 45° даёт ~110 px — + // манекен мелкий, порог 50; фазы 0 и 4 цикла совпадают — норма двухшагового цикла) + const pairs = []; + for (const row of [0, 1]) + for (let col = 0; col + 1 < info.cols; col++) { + const a = cells[row * info.cols + col], b = cells[row * info.cols + col + 1]; + pairs.push(cellDiff(full, full, a.px, a.py, b.px, b.py)); + } + const same = pairs.filter((d) => d <= 50); + check('соседние фазы различаются (6 пар)', same.length === 0, + same.length ? { same } : { minDiff: Math.min(...pairs) }); + + // Числовые снапшоты поз: противофазы, контакт стоп, качание корня + const ps = []; + for (let i = 0; i < info.frames; i++) ps.push(await client.call('poseStats', i)); + const mid = ps[2], zero = ps[0]; + check('ноги и руки в противофазе (i=2)', mid.legR === -mid.legL && mid.armR === -mid.armL, { legL: mid.legL, armL: mid.armL }); + check('контакт стоп в i=0/i=4, мах в i=2 поднимает стопы', + Math.abs(ps[0].footMinL - 0.5) < 0.05 && Math.abs(ps[4].footMinL - 0.5) < 0.05 && + mid.footMinL > 0.8 && mid.footMinR > 0.8, + { contact: ps[0].footMinL, midL: mid.footMinL, midR: mid.footMinR }); + check('корень: в контакте y=0, в шаге приседает и качается вбок', + Math.abs(zero.rootPos[1]) < 0.01 && mid.rootPos[1] < -0.2 && + Math.abs(mid.rootPos[0]) > 0.1 && Math.abs(ps[6].rootPos[0] + mid.rootPos[0]) < 0.01, + { zeroY: zero.rootPos[1], midY: mid.rootPos[1], swayL: mid.rootPos[0], swayR: ps[6].rootPos[0] }); + + report.ok = report.checks.every((c) => c.ok); + } finally { + await browser.close(); + } +} catch (e) { + check('прогон без исключений', false, String(e)); +} finally { + server.stop(); +} + +console.log(JSON.stringify(report, null, 2)); +process.exit(report.ok ? 0 : 1); + +/** Экранные координаты мировой точки — зеркалим VoxelRenderer (см. turntable.mjs). */ +function worldToPx(p, info) { + const norm = (v) => { const l = Math.hypot(...v); return v.map((c) => c / l); }; + const cross = (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], + ]; + const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + const dir = norm([Math.cos(Math.PI / 4) * 0.5, 1, Math.sin(Math.PI / 4) * 0.5]); + const xAxis = norm(cross([0, 1, 0], dir)); + const yAxis = cross(dir, xAxis); + const eye = info.target.map((t, i) => t + dir[i] * info.viewSize * 4); + const rel = p.map((c, i) => c - eye[i]); + const aspect = info.width / info.height; + const ndcX = dot(rel, xAxis) / (info.viewSize * aspect); + const ndcY = dot(rel, yAxis) / info.viewSize; + return [ + ((ndcX + 1) / 2) * info.width * info.scale, + (1 - (ndcY + 1) / 2) * info.height * info.scale, + ]; +} + +/** Число различающихся пикселей в эллипсе вокруг центров ячеек (см. turntable.mjs). */ +function cellDiff(a, b, pxA, pyA, pxB = pxA, pyB = pyA) { + const RX = 24, RY = 21; + let diff = 0; + for (let dy = -RY; dy <= RY; dy++) + for (let dx = -RX; dx <= RX; dx++) { + if ((dx / RX) ** 2 + (dy / RY) ** 2 > 1) continue; + const ia = pixelIndex(a, pxA + dx, pyA + dy); + const ib = pixelIndex(b, pxB + dx, pyB + dy); + for (let k = 0; k < 3; k++) if (Math.abs(a.rgba[ia + k] - b.rgba[ib + k]) > 8) { diff++; break; } + } + return diff; +} + +function pixelIndex(png, x, y) { + const cx = Math.min(png.width - 1, Math.max(0, Math.round(x))); + const cy = Math.min(png.height - 1, Math.max(0, Math.round(y))); + return (cy * png.width + cx) * 4; +} \ No newline at end of file diff --git a/v2/packages/engine/src/animation/__tests__/clip.test.ts b/v2/packages/engine/src/animation/__tests__/clip.test.ts new file mode 100644 index 0000000..ae4d86d --- /dev/null +++ b/v2/packages/engine/src/animation/__tests__/clip.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import { samplePose, validateClip, footMinY } from '../clip'; +import type { AnimClip } from '../clip'; +import { makeRig } from '../rig'; + +/** Простой клип-ходьба: кость «leg» качается, кость «root» приседает. */ +function walkClip(): AnimClip { + const bone = (rotX: number, rootY: number) => ({ + bones: { leg: { rot: [rotX, 0, 0] as const }, root: { pos: [0, rootY, 0] as const } }, + }); + return { + name: 'walk', + duration: 1, + loop: true, + keys: [ + { t: 0, ...bone(0, 0) }, + { t: 0.25, ...bone(0.5, -0.3) }, + { t: 0.5, ...bone(0, 0) }, + { t: 0.75, ...bone(-0.5, -0.3) }, + { t: 1, ...bone(0, 0) }, + ], + }; +} + +describe('samplePose (семплинг позы)', () => { + it('в ключах — точные значения', () => { + const pose = samplePose(walkClip(), 0.25); + expect(pose.leg?.rot).toEqual([0.5, 0, 0]); + expect(pose.root?.pos).toEqual([0, -0.3, 0]); + }); + + it('между ключами — линейная интерполяция по каналам', () => { + const pose = samplePose(walkClip(), 0.375); // середина между 0.25 и 0.5 + expect(pose.leg?.rot?.[0]).toBeCloseTo(0.25); + expect(pose.root?.pos?.[1]).toBeCloseTo(-0.15); + }); + + it('зацикленный клип: t за пределами длительности заворачивается', () => { + const a = samplePose(walkClip(), 1.25); + const b = samplePose(walkClip(), 0.25); + expect(a.leg?.rot?.[0]).toBeCloseTo(b.leg?.rot?.[0] ?? NaN); + const c = samplePose(walkClip(), -0.75); + expect(c.leg?.rot?.[0]).toBeCloseTo(b.leg?.rot?.[0] ?? NaN); + }); + + it('незацикленный клип: за концом держит последний ключ', () => { + const clip = { ...walkClip(), loop: false }; + expect(samplePose(clip, 5).leg?.rot?.[0]).toBeCloseTo(0); + }); + + it('кость только в одном из соседних ключей — берётся из ближнего целиком', () => { + const clip: AnimClip = { + name: 'gaps', duration: 1, loop: false, + keys: [ + { t: 0, bones: { arm: { rot: [1, 0, 0] } } }, + { t: 1, bones: {} }, + ], + }; + const mid = samplePose(clip, 0.9); + expect(mid.arm?.rot).toEqual([1, 0, 0]); // ближний ключ 0 несёт канал целиком + }); +}); + +describe('validateClip (числовая валидация)', () => { + it('корректный цикл — пустой список проблем', () => { + expect(validateClip(walkClip())).toEqual([]); + }); + + it('ловит разрыв цикла на стыке', () => { + const clip = walkClip(); + clip.keys[clip.keys.length - 1] = { t: 1, bones: { leg: { rot: [0.3, 0, 0] } } }; + const problems = validateClip(clip); + expect(problems.some((p) => p.includes('рвётся'))).toBe(true); + }); + + it('ловит немонотонность и выход t за границы', () => { + const clip = walkClip(); + clip.keys[2].t = 0.1; + const problems = validateClip(clip); + expect(problems.some((p) => p.includes('не больше предыдущего'))).toBe(true); + clip.keys[2].t = 2; + expect(validateClip(clip).some((p) => p.includes('вне'))).toBe(true); + }); + + it('ловит пустой клип и нулевую длительность', () => { + expect(validateClip({ name: 'e', duration: 0, loop: false, keys: [] }).length).toBe(2); + }); +}); + +describe('footMinY (контакт стопы)', () => { + it('кость, поднятая позой, поднимает свои воксели', () => { + // модель 1×2×2: воксели (0,0,0) кость foot, (0,1,0) кость body + const model = { + size: [1, 2, 2] as [number, number, number], + palette: { 1: '#101010', 2: '#202020' }, + data: btoa(String.fromCharCode(1, 2, 0, 0)), + }; + const rig = makeRig( + [ + { name: 'root', parent: -1, pivot: [0.5, 1, 0.5] }, + { name: 'foot', parent: 0, pivot: [0.5, 0.5, 0.5] }, + ], + model, + [1, 0, -1, -1], + ); + const clip: AnimClip = { + name: 'hop', duration: 1, loop: true, + keys: [ + { t: 0, bones: { foot: { pos: [0, 0, 0] } } }, + { t: 0.5, bones: { foot: { pos: [0, 2, 0] } } }, + { t: 1, bones: { foot: { pos: [0, 0, 0] } } }, + ], + }; + expect(footMinY(rig, model, clip, 'foot', 0)).toBeCloseTo(0.5); // центр вокселя на y=0.5 + expect(footMinY(rig, model, clip, 'foot', 0.25)).toBeCloseTo(1.5); // интерполяция +1 + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/animation/__tests__/locomotion.test.ts b/v2/packages/engine/src/animation/__tests__/locomotion.test.ts new file mode 100644 index 0000000..959693c --- /dev/null +++ b/v2/packages/engine/src/animation/__tests__/locomotion.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { walkClip } from '../locomotion'; +import { samplePose, validateClip, footMinY } from '../clip'; +import { makeRig } from '../rig'; +import type { VoxelModel } from '../../models/format'; + +const PARAMS = { duration: 0.8, legSwing: 0.5, armSwing: 0.3, bob: 0.2, sway: 0.1 }; + +describe('walkClip (процедурная локомоция)', () => { + it('детерминирован', () => { + expect(JSON.stringify(walkClip(PARAMS))).toBe(JSON.stringify(walkClip(PARAMS))); + }); + + it('проходит числовую валидацию (цикл замкнут, ключи монотонны)', () => { + expect(validateClip(walkClip(PARAMS))).toEqual([]); + }); + + it('ноги в противофазе, рука против одноимённой ноги', () => { + const clip = walkClip(PARAMS); + const q = samplePose(clip, PARAMS.duration * 0.25); // левая нога в махе + expect(q.legL?.rot?.[0]).toBeCloseTo(PARAMS.legSwing); + expect(q.legR?.rot?.[0]).toBeCloseTo(-PARAMS.legSwing); + expect(q.armL?.rot?.[0]).toBeCloseTo(-PARAMS.armSwing); + expect(q.armR?.rot?.[0]).toBeCloseTo(PARAMS.armSwing); + }); + + it('корень: в контакте y=0, в полном шаге приседает, качается вбок', () => { + const clip = walkClip(PARAMS); + const contact = samplePose(clip, 0); + expect(contact.root?.pos?.[1]).toBeCloseTo(0); + const stride = samplePose(clip, PARAMS.duration * 0.25); + expect(stride.root?.pos?.[1]).toBeCloseTo(-PARAMS.bob); + expect(stride.root?.pos?.[0]).toBeCloseTo(PARAMS.sway); + const strideR = samplePose(clip, PARAMS.duration * 0.75); + expect(strideR.root?.pos?.[0]).toBeCloseTo(-PARAMS.sway); + }); + + it('контакт стопы: в опоре стопа на земле, в махе поднята', () => { + // манекен-заглушка: нога 5 вокселей вниз от pivot на y=5 + const model: VoxelModel = { + size: [1, 6, 1], + palette: { 6: '#5b5b66' }, + data: btoa(String.fromCharCode(6, 6, 6, 6, 6, 6)), + }; + const rig = makeRig( + [ + { name: 'root', parent: -1, pivot: [0.5, 0, 0.5] }, + { name: 'legL', parent: 0, pivot: [0.5, 5, 0.5] }, + ], + model, + [1, 1, 1, 1, 1, 0], + ); + const clip = walkClip({ ...PARAMS, legSwing: 0.5 }); + const ground = footMinY(rig, model, clip, 'legL', 0); + const swing = footMinY(rig, model, clip, 'legL', PARAMS.duration * 0.25); + expect(ground).toBeCloseTo(0.5, 2); // стопа на земле (центр нижнего вокселя 0.5) + // мах поднимает стопу (с поправкой на приседание корня −bob в этой же фазе) + expect(swing).toBeGreaterThan(ground + PARAMS.legSwing * 0.5); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/animation/__tests__/mat.test.ts b/v2/packages/engine/src/animation/__tests__/mat.test.ts new file mode 100644 index 0000000..27e0a36 --- /dev/null +++ b/v2/packages/engine/src/animation/__tests__/mat.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { eulerMat, composeAffine, affinePoint, rotateAround, mat3Mul } from '../mat'; + +const close = (a: readonly number[], b: readonly number[], eps = 1e-9): void => { + expect(a.length).toBe(b.length); + a.forEach((v, i) => expect(Math.abs(v - b[i])).toBeLessThan(eps)); +}; + +describe('mat (аффинная математика анимации)', () => { + it('поворот на 90° вокруг X: Y → Z', () => { + const p = affinePoint({ m: eulerMat([Math.PI / 2, 0, 0]), t: [0, 0, 0] }, [0, 1, 0]); + close(p, [0, 0, 1]); + }); + + it('поворот на 90° вокруг Z: X → Y', () => { + const p = affinePoint({ m: eulerMat([0, 0, Math.PI / 2]), t: [0, 0, 0] }, [1, 0, 0]); + close(p, [0, 1, 0]); + }); + + it('rotateAround: поворот вокруг точки сохраняет саму точку', () => { + const a = rotateAround([0, 0, Math.PI], [2, 3, 1], [0, 0, 0]); + close(affinePoint(a, [2, 3, 1]), [2, 3, 1]); + // противоположная точка pivota уходит на противоположную сторону + close(affinePoint(a, [3, 3, 1]), [1, 3, 1]); + }); + + it('rotateAround: сдвиг pos двигает pivot', () => { + const a = rotateAround([0, 0, 0], [2, 3, 1], [5, 0, -2]); + close(affinePoint(a, [2, 3, 1]), [7, 3, -1]); + close(affinePoint(a, [0, 0, 0]), [5, 0, -2]); + }); + + it('композиция родителя и локального трансформа = последовательное применение', () => { + const parent = rotateAround([0, 0, Math.PI / 2], [0, 0, 0], [1, 0, 0]); + const local = rotateAround([0, 0, 0], [0, 0, 0], [0, 2, 0]); + const combined = composeAffine(parent, local); + const direct = affinePoint(parent, affinePoint(local, [3, 1, 0])); + close(affinePoint(combined, [3, 1, 0]), direct); + }); + + it('mat3Mul ассоциативен на примере двух поворотов', () => { + const rx = eulerMat([0.3, 0, 0]); + const ry = eulerMat([0, 0.7, 0]); + const p: [number, number, number] = [0.4, -0.9, 1.3]; + const ab = affinePoint({ m: mat3Mul(rx, ry), t: [0, 0, 0] }, p); + const aOfb = affinePoint({ m: rx, t: [0, 0, 0] }, affinePoint({ m: ry, t: [0, 0, 0] }, p)); + close(ab, aOfb); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/animation/__tests__/rig.test.ts b/v2/packages/engine/src/animation/__tests__/rig.test.ts new file mode 100644 index 0000000..808cf11 --- /dev/null +++ b/v2/packages/engine/src/animation/__tests__/rig.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { makeRig, bindNearest, validateRig, poseVoxels, boneWorlds } from '../rig'; +import type { VoxelModel } from '../../models/format'; +import { affinePoint } from '../mat'; + +/** Модель 3×1×1: три вокселя вдоль X с разными слотами. */ +function model3(): VoxelModel { + return { + size: [3, 1, 1], + palette: { 1: '#101010', 2: '#202020', 3: '#303030' }, + data: btoa(String.fromCharCode(1, 2, 3)), + }; +} + +/** Рига: корень в левом вокселе, «кончик» в правом. */ +function rig3() { + return makeRig( + [ + { name: 'root', parent: -1, pivot: [0.5, 0.5, 0.5] }, + { name: 'tip', parent: 0, pivot: [2.5, 0.5, 0.5] }, + ], + model3(), + [0, 0, 1], + ); +} + +describe('makeRig/bindNearest (привязка вокселей к костям)', () => { + it('ближайший pivot, при равенстве — меньший индекс кости', () => { + const binding = bindNearest( + [ + { name: 'l', parent: -1, pivot: [0.5, 0.5, 0.5] }, + { name: 'r', parent: 0, pivot: [2.5, 0.5, 0.5] }, + ], + model3(), + ); + expect(binding).toEqual([0, 0, 1]); // воксель 1 равноудалён (1.0 vs 1.0) — остаётся меньший индекс 0 + }); + + it('явная привязка передаётся как есть', () => { + expect(rig3().binding).toEqual([0, 0, 1]); + }); +}); + +describe('validateRig (числовая валидация агентом)', () => { + it('корректная рига — пустой список проблем', () => { + expect(validateRig(rig3(), model3())).toEqual([]); + }); + + it('ловит цикл (родитель не раньше кости) и чужой индекс родителя', () => { + const rig = rig3(); + rig.bones[0].parent = 1; + const problems = validateRig(rig, model3()); + expect(problems.some((p) => p.includes('не раньше'))).toBe(true); + rig.bones[0].parent = -1; + rig.bones[1].parent = 5; + expect(validateRig(rig, model3()).some((p) => p.includes('вне риги'))).toBe(true); + }); + + it('ловит длину привязки и пустые/висячие воксели', () => { + const model = model3(); + expect(validateRig({ bones: rig3().bones, binding: [0, 0] }, model).some((p) => p.includes('байт'))).toBe(true); + expect(validateRig({ bones: rig3().bones, binding: [0, -1, 1] }, model).some((p) => p.includes('не привязан'))).toBe(true); + expect(validateRig({ bones: rig3().bones, binding: [0, 0, 7] }, model).some((p) => p.includes('вне риги'))).toBe(true); + }); +}); + +describe('poseVoxels (деформация)', () => { + it('сдвиг корня двигает все воксели', () => { + const posed = poseVoxels(rig3(), model3(), { root: { pos: [1, 0, 0] } }); + expect(posed.map((v) => v.pos[0])).toEqual([1.5, 2.5, 3.5]); + expect(posed.map((v) => v.color)).toEqual(['#101010', '#202020', '#303030']); + }); + + it('поворот кости двигает только её воксели, вокруг pivot', () => { + const posed = poseVoxels(rig3(), model3(), { tip: { rot: [0, 0, Math.PI / 2] } }); + // воксель кончика (центр 2.5,0.5,0.5) повёрнут вокруг (2.5,0.5,0.5) — на месте, + // но повернуть нечего: воксель совпадает с pivot; проверим сдвигом вниз на 1 + const posed2 = poseVoxels(rig3(), model3(), { tip: { pos: [0, -1, 0] } }); + expect(posed2[0].pos).toEqual([0.5, 0.5, 0.5]); + expect(posed2[2].pos).toEqual([2.5, -0.5, 0.5]); + expect(posed[2].pos).toEqual([2.5, 0.5, 0.5]); + }); + + it('поворот родителя тянет за собой детей (иерархия)', () => { + const posed = poseVoxels(rig3(), model3(), { root: { rot: [0, 0, Math.PI / 2] } }); + // центр вокселя 2: (2.5,0.5,0.5) → поворот Z на 90° вокруг (0.5,0.5,0.5) → (0.5,2.5,0.5) + expect(posed[2].pos[0]).toBeCloseTo(0.5); + expect(posed[2].pos[1]).toBeCloseTo(2.5); + // мир-трансформ ребёнка композиция родителя + const worlds = boneWorlds(rig3(), {}); + expect(affinePoint(worlds[1], [2.5, 0.5, 0.5])).toEqual([2.5, 0.5, 0.5]); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/animation/clip.ts b/v2/packages/engine/src/animation/clip.ts new file mode 100644 index 0000000..54e8fef --- /dev/null +++ b/v2/packages/engine/src/animation/clip.ts @@ -0,0 +1,110 @@ +/** + * Анимационный клип — ключевые кадры как данные (docs/plan.md, «анимация = + * данные, не кадры»). Клип = набор ключей {t, кости: rot/pos}; поза в момент + * t — линейная интерполяция между соседними ключами (по каждой кости и каналу + * отдельно; канал, отсутствующий в ключе, берётся из ближайшего ключа с ним). + * Валидация числовая: границы и монотонность ключей, для зацикленных клипов — + * непрерывность цикла (первый ключ = последнему). + */ +import type { Pose, VoxelRig } from './rig'; +import { poseVoxels } from './rig'; +import type { VoxelModel } from '../models/format'; +import type { Vec3 } from './mat'; + +/** Кадр одной кости: поворот (рад) и/или сдвиг (воксельных единиц). */ +export interface BoneFrame { + rot?: Vec3; + pos?: Vec3; +} + +/** Ключевой кадр: момент t (сек) + каналы костей по именам. */ +export interface ClipKey { + t: number; + bones: Record; +} + +export interface AnimClip { + name: string; + /** Длительность цикла в секундах (> 0). */ + duration: number; + /** Зацикленный клип: первый ключ на t=0 обязан совпадать с последним на t=duration. */ + loop: boolean; + /** Ключи отсортированы по t строго возрастание. */ + keys: ClipKey[]; +} + +const EPS = 1e-6; + +/** Поза в момент t: линейная интерполяция каналов между соседними ключами. */ +export function samplePose(clip: AnimClip, t: number): Pose { + const pose: Record = {}; + if (clip.keys.length === 0) return pose; + const time = clip.loop + ? ((t % clip.duration) + clip.duration) % clip.duration + : Math.min(clip.duration, Math.max(0, t)); + // соседние ключи: keys[i].t ≤ time < keys[i+1].t (после последнего — последний) + let i = 0; + while (i + 1 < clip.keys.length && clip.keys[i + 1].t <= time) i++; + const a = clip.keys[i], b = clip.keys[Math.min(i + 1, clip.keys.length - 1)]; + const span = b.t - a.t; + const k = span > EPS ? (time - a.t) / span : 0; + const names = new Set([...Object.keys(a.bones), ...Object.keys(b.bones)]); + for (const name of names) { + const fa = a.bones[name], fb = b.bones[name]; + const rot = lerpVec(fa?.rot, fb?.rot, k); + const pos = lerpVec(fa?.pos, fb?.pos, k); + if (rot || pos) pose[name] = { ...(rot && { rot }), ...(pos && { pos }) }; + } + return pose; +} + +/** Линейная интерполяция вектора; отсутствующая сторона — вектор с другой стороны. */ +function lerpVec(a: Vec3 | undefined, b: Vec3 | undefined, k: number): Vec3 | undefined { + if (!a && !b) return undefined; + const va = a ?? b!, vb = b ?? a!; + return [ + va[0] + (vb[0] - va[0]) * k, + va[1] + (vb[1] - va[1]) * k, + va[2] + (vb[2] - va[2]) * k, + ]; +} + +/** Список проблем клипа; пустой — клип корректен. */ +export function validateClip(clip: AnimClip): string[] { + const problems: string[] = []; + if (clip.duration <= 0) problems.push(`длительность ${clip.duration} ≤ 0`); + if (clip.keys.length === 0) problems.push('нет ключевых кадров'); + clip.keys.forEach((key, i) => { + if (key.t < 0 || key.t > clip.duration) problems.push(`ключ ${i}: t=${key.t} вне [0, ${clip.duration}]`); + if (i > 0 && key.t <= clip.keys[i - 1].t) problems.push(`ключ ${i}: t=${key.t} не больше предыдущего`); + }); + if (clip.loop && clip.keys.length >= 2) { + const first = clip.keys[0], last = clip.keys[clip.keys.length - 1]; + if (first.t !== 0) problems.push(`цикл: первый ключ на t=${first.t}, жду 0`); + if (last.t !== clip.duration) problems.push(`цикл: последний ключ на t=${last.t}, жду ${clip.duration}`); + for (const name of Object.keys(first.bones)) { + const fa = first.bones[name], fb = last.bones[name]; + if (!fb) continue; // кость есть только в начале — поза берётся из ближайшего ключа + for (const ch of ['rot', 'pos'] as const) { + const va = fa[ch], vb = fb[ch]; + if (!va || !vb) continue; + if (va.some((v, j) => Math.abs(v - vb[j]) > EPS)) { + problems.push(`цикл: кость «${name}» канал ${ch} рвётся на стыке (${va} → ${vb})`); + } + } + } + } + return problems; +} + +/** + * Контакт стопы: минимальная мировая Y вокселей кости в фазе опоры. + * Числовая проверка «y кости ≈ 0 в фазе опоры» (docs/plan.md) — работает + * на связочной модели, где стопы стоят на y ≈ 0. + */ +export function footMinY(rig: VoxelRig, model: VoxelModel, clip: AnimClip, bone: string, t: number): number { + const posed = poseVoxels(rig, model, samplePose(clip, t)); + const index = rig.bones.findIndex((b) => b.name === bone); + const ys = posed.filter((v) => rig.binding[v.index] === index).map((v) => v.pos[1]); + return ys.length ? Math.min(...ys) : NaN; +} \ No newline at end of file diff --git a/v2/packages/engine/src/animation/locomotion.ts b/v2/packages/engine/src/animation/locomotion.ts new file mode 100644 index 0000000..a3b905d --- /dev/null +++ b/v2/packages/engine/src/animation/locomotion.ts @@ -0,0 +1,48 @@ +/** + * Процедурная локомоция (docs/plan.md): walk-цикл генерируется параметрами, + * а не авторится кадрами. Два шага за цикл: левая и правая нога в противофазе + * (sin θ и sin(θ+π)), руки — в противофазе одноимённой ноге, корень приседает + * дважды за цикл (в фазах полного шага) и качается вбок один раз. + * Требования к костям: «legL»/«legR» (pitch вокруг X), «armL»/«armR», «root». + * Отсутствующие кости просто не получают каналов — семплинг это допускает. + */ +import type { AnimClip, ClipKey } from './clip'; + +export interface WalkParams { + /** Длительность цикла (сек), > 0. */ + duration: number; + /** Амплитуда ног (рад, ± вокруг нуля). */ + legSwing: number; + /** Амплитуда рук (рад, ± вокруг нуля). */ + armSwing: number; + /** Приседание корня в фазах полного шага (воксельных единиц, ≥ 0). */ + bob: number; + /** Боковое качание корня (воксельных единиц; 0 — выключено). */ + sway?: number; +} + +const KEY_STEPS = 8; // ключей на цикл + 1 (замыкающий) — линейный семплинг гладкости не даёт, но дёшев + +/** Walk-цикл из параметров; детерминирован, проходит validateClip (цикл замкнут). */ +export function walkClip(params: WalkParams, name = 'walk'): AnimClip { + const keys: ClipKey[] = []; + for (let i = 0; i <= KEY_STEPS; i++) { + const t = (params.duration * i) / KEY_STEPS; + const th = (2 * Math.PI * i) / KEY_STEPS; // фаза цикла + const legL = params.legSwing * Math.sin(th); + const armL = -params.armSwing * Math.sin(th); // рука против одноимённой ноги + const bobY = -params.bob * Math.abs(Math.sin(th)); // приседание дважды за цикл + const swayX = (params.sway ?? 0) * Math.sin(th); + keys.push({ + t, + bones: { + root: { pos: [swayX, bobY, 0] }, + legL: { rot: [legL, 0, 0] }, + legR: { rot: [-legL, 0, 0] }, + armL: { rot: [armL, 0, 0] }, + armR: { rot: [-armL, 0, 0] }, + }, + }); + } + return { name, duration: params.duration, loop: true, keys }; +} \ No newline at end of file diff --git a/v2/packages/engine/src/animation/mat.ts b/v2/packages/engine/src/animation/mat.ts new file mode 100644 index 0000000..963868e --- /dev/null +++ b/v2/packages/engine/src/animation/mat.ts @@ -0,0 +1,68 @@ +/** + * Минимальная аффинная математика анимации — чистая, без three.js + * (урок №3: математика отдельно от вьюх, тестируется без рендера). + * Конвенция колонок: точка справа, p' = M·p + t. Порядок Эйлера — + * применяем X, потом Y, потом Z (R = Rz·Ry·Rx). + */ +export type Vec3 = readonly [number, number, number]; +export type Mat3 = readonly [number, number, number, number, number, number, number, number, number]; + +/** Аффинный трансформ: поворот m плюс перенос t. */ +export interface Affine { + m: Mat3; + t: Vec3; +} + +export const IDENTITY_AFFINE: Affine = { m: [1, 0, 0, 0, 1, 0, 0, 0, 1], t: [0, 0, 0] }; + +/** Матрица поворота из эйлеровых углов (рад), порядок X → Y → Z. */ +export function eulerMat(rot: Vec3): Mat3 { + const [rx, ry, rz] = rot; + const cx = Math.cos(rx), sx = Math.sin(rx); + const cy = Math.cos(ry), sy = Math.sin(ry); + const cz = Math.cos(rz), sz = Math.sin(rz); + // Rz·Ry·Rx, развёрнуто (row-major) + return [ + cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx, + sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx, + -sy, cy * sx, cy * cx, + ]; +} + +/** Композиция: сначала local, потом parent (p → parent(local(p))). */ +export function composeAffine(parent: Affine, local: Affine): Affine { + // parent(local(p)) = parent.m·local.m·p + (parent.m·local.t + parent.t) + return { m: mat3Mul(parent.m, local.m), t: affinePoint(parent, local.t) }; +} + +/** Умножение матриц a·b. */ +export function mat3Mul(a: Mat3, b: Mat3): Mat3 { + const out: number[] = []; + for (let r = 0; r < 3; r++) + for (let c = 0; c < 3; c++) + out.push(a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c]); + return out as unknown as Mat3; +} + +/** Применение аффинного трансформа к точке. */ +export function affinePoint(a: Affine, p: Vec3): Vec3 { + const m = a.m; + return [ + m[0] * p[0] + m[1] * p[1] + m[2] * p[2] + a.t[0], + m[3] * p[0] + m[4] * p[1] + m[5] * p[2] + a.t[1], + m[6] * p[0] + m[7] * p[1] + m[8] * p[2] + a.t[2], + ]; +} + +/** Сложение векторов. */ +export function addVec(a: Vec3, b: Vec3): Vec3 { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; +} + +/** Аффинный трансформ «повернуть вокруг точки pivot и сдвинуть на pos». */ +export function rotateAround(rot: Vec3, pivot: Vec3, pos: Vec3): Affine { + const m = eulerMat(rot); + // p → R·(p − pivot) + pivot + pos = R·p + (pivot + pos − R·pivot) + const rp = affinePoint({ m, t: [0, 0, 0] }, pivot); + return { m, t: addVec(addVec(pivot, pos), [-rp[0], -rp[1], -rp[2]]) }; +} \ No newline at end of file diff --git a/v2/packages/engine/src/animation/rig.ts b/v2/packages/engine/src/animation/rig.ts new file mode 100644 index 0000000..2bef013 --- /dev/null +++ b/v2/packages/engine/src/animation/rig.ts @@ -0,0 +1,123 @@ +/** + * Рига воксельной модели — «анимация = данные» (docs/plan.md, направление v2). + * Кость = имя + родитель + pivot (точка вращения в связочном пространстве + * модели, где центр вокселя (x,y,z) — это (x+0.5, y+0.5, z+0.5)). + * Привязка вокселей к костям — детерминированная: у генератора части известны + * заранее (привязка передаётся явно), для чужих моделей — bindNearest + * (ближайший pivot, при равенстве меньший индекс кости). + * Деформация жёсткая на кость (кости не растягиваются — дистанции внутри + * кости сохраняются по построению). + */ +import { decodeModel } from '../models/format'; +import type { VoxelModel } from '../models/format'; +import { composeAffine, rotateAround, affinePoint } from './mat'; +import type { Affine, Vec3 } from './mat'; + +export interface RigBone { + name: string; + /** Индекс кости-родителя; -1 у корня. Родитель всегда с меньшим индексом. */ + parent: number; + pivot: Vec3; +} + +export interface VoxelRig { + bones: RigBone[]; + /** Воксель i (i = x + sx·(y + sy·z)) → индекс кости; -1 у пустых. */ + binding: number[]; +} + +/** Поза: по имени кости — поворот (рад) и сдвиг (воксельных единиц). */ +export interface Pose { + readonly [bone: string]: { rot?: Vec3; pos?: Vec3 } | undefined; +} + +/** Деформированный воксель: позиция центра, цвет слота сцены и индекс в сетке. */ +export interface PosedVoxel { + pos: Vec3; + color: string; + /** Индекс вокселя в сетке модели (i = x + sx·(y + sy·z)) — для привязки к кости. */ + index: number; +} + +/** Привязка «ближайший pivot» для моделей без явной разметки частей. */ +export function bindNearest(bones: RigBone[], model: VoxelModel): number[] { + const grid = decodeModel(model); + const binding = new Array(grid.data.length).fill(-1); + const centers = bones.map((b) => [b.pivot[0], b.pivot[1], b.pivot[2]]); + for (let i = 0; i < grid.data.length; i++) { + if (grid.data[i] === 0) continue; + const x = i % grid.sx, y = Math.floor(i / grid.sx) % grid.sy, z = Math.floor(i / (grid.sx * grid.sy)); + const c = [x + 0.5, y + 0.5, z + 0.5]; + let best = 0, bestD = Infinity; + for (let b = 0; b < centers.length; b++) { + const d = (centers[b][0] - c[0]) ** 2 + (centers[b][1] - c[1]) ** 2 + (centers[b][2] - c[2]) ** 2; + if (d < bestD) { bestD = d; best = b; } // при равенстве остаётся меньший индекс + } + binding[i] = best; + } + return binding; +} + +/** Рига из костей: привязка передаётся явно (генератор знает части) или по ближайшему pivot. */ +export function makeRig(bones: RigBone[], model: VoxelModel, binding?: number[]): VoxelRig { + return { bones, binding: binding ?? bindNearest(bones, model) }; +} + +/** Список проблем риги (числовая валидация агентом); пустой — рига корректна. */ +export function validateRig(rig: VoxelRig, model: VoxelModel): string[] { + const problems: string[] = []; + const { bones, binding } = rig; + if (bones.length === 0) problems.push('нет костей'); + const seen = new Set(); + bones.forEach((b, i) => { + if (!b.name) problems.push(`кость ${i}: пустое имя`); + if (seen.has(b.name)) problems.push(`кость ${i}: имя «${b.name}» повторяется`); + seen.add(b.name); + if (b.parent < -1 || b.parent >= bones.length) problems.push(`кость «${b.name}»: родитель ${b.parent} вне риги`); + else if (b.parent >= i) problems.push(`кость «${b.name}»: родитель ${b.parent} не раньше самой кости (цикл)`); + }); + const grid = decodeModel(model); + if (binding.length !== grid.data.length) { + problems.push(`привязка ${binding.length} байт, а вокселей ${grid.data.length}`); + return problems; + } + for (let i = 0; i < binding.length; i++) { + const empty = grid.data[i] === 0; + if (empty && binding[i] !== -1) problems.push(`воксель ${i} пуст, но привязан к кости ${binding[i]}`); + if (!empty && binding[i] === -1) problems.push(`воксель ${i} занят, но не привязан ни к какой кости`); + if (!empty && (binding[i] < 0 || binding[i] >= bones.length)) { + problems.push(`воксель ${i} привязан к кости ${binding[i]} вне риги`); + break; + } + } + return problems; +} + +/** Мир-трансформы костей (связочное пространство → мир) по цепочкам родителей. */ +export function boneWorlds(rig: VoxelRig, pose: Pose): Affine[] { + // родители всегда раньше детей по индексу (validateRig) — считаем итеративно + const worlds: Affine[] = []; + rig.bones.forEach((bone, i) => { + const p = pose[bone.name]; + const local = rotateAround(p?.rot ?? [0, 0, 0], bone.pivot, p?.pos ?? [0, 0, 0]); + worlds[i] = bone.parent >= 0 && bone.parent < i + ? composeAffine(worlds[bone.parent], local) + : local; + }); + return worlds; +} + +/** Деформированные воксели модели в позе (центры в мировых координатах). */ +export function poseVoxels(rig: VoxelRig, model: VoxelModel, pose: Pose): PosedVoxel[] { + const grid = decodeModel(model); + const worlds = boneWorlds(rig, pose); + const out: PosedVoxel[] = []; + for (let i = 0; i < grid.data.length; i++) { + const slot = grid.data[i]; + if (slot === 0) continue; + const x = i % grid.sx, y = Math.floor(i / grid.sx) % grid.sy, z = Math.floor(i / (grid.sx * grid.sy)); + const pos = affinePoint(worlds[rig.binding[i]], [x + 0.5, y + 0.5, z + 0.5]); + out.push({ pos, color: model.palette[String(slot)], index: i }); + } + return out; +} \ No newline at end of file diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index faeaf14..ed1626f 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -23,7 +23,7 @@ export { meshChunk, chunkBounds } from './world/worldMesher'; export { Chunk, CHUNK_SX, CHUNK_SZ } from './world/chunk'; export { VoxelRenderer } from './render/voxelRenderer'; -export type { VoxelRendererOptions } from './render/voxelRenderer'; +export type { VoxelRendererOptions, VoxelCloud } from './render/voxelRenderer'; export { Rng } from './math/rng'; export { encodeModel, decodeModel, validateModel, assertModelValid } from './models/format'; export type { VoxelModel } from './models/format'; @@ -32,4 +32,14 @@ export { stampModel } from './models/stamp'; export type { StampClip } from './models/stamp'; export { parseVox, exportVox } from './models/vox'; -export { remapSlots } from './models/paletteMap'; \ No newline at end of file +export { remapSlots } from './models/paletteMap'; +export { mannequin, MANNEQUIN_BONES } from './models/mannequin'; +export type { Mannequin, MannequinBone } from './models/mannequin'; +export { eulerMat, mat3Mul, composeAffine, affinePoint, rotateAround, addVec, IDENTITY_AFFINE } from './animation/mat'; +export type { Vec3, Mat3, Affine } from './animation/mat'; +export { makeRig, bindNearest, validateRig, boneWorlds, poseVoxels } from './animation/rig'; +export type { RigBone, VoxelRig, Pose, PosedVoxel } from './animation/rig'; +export { samplePose, validateClip, footMinY } from './animation/clip'; +export type { AnimClip, ClipKey, BoneFrame } from './animation/clip'; +export { walkClip } from './animation/locomotion'; +export type { WalkParams } from './animation/locomotion'; \ No newline at end of file diff --git a/v2/packages/engine/src/models/__tests__/mannequin.test.ts b/v2/packages/engine/src/models/__tests__/mannequin.test.ts new file mode 100644 index 0000000..328567b --- /dev/null +++ b/v2/packages/engine/src/models/__tests__/mannequin.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { mannequin } from '../mannequin'; +import { validateRig, poseVoxels } from '../../animation/rig'; +import { decodeModel } from '../format'; + +describe('mannequin (манекен + рига)', () => { + it('детерминирован', () => { + expect(JSON.stringify(mannequin())).toBe(JSON.stringify(mannequin())); + }); + + it('ригa проходит числовую валидацию', () => { + const m = mannequin(); + expect(validateRig(m.rig, m.model)).toEqual([]); + }); + + it('состав частей: 5 ног ×2, 4 руки ×2, торс 12, голова 2', () => { + const { model, rig } = mannequin(); + const g = decodeModel(model); + expect(g.count()).toBe(32); // 10 ног + 12 торс + 2 голова + 8 рук + const byBone = [0, 0, 0, 0, 0]; + for (let i = 0; i < g.data.length; i++) + if (g.data[i] !== 0) byBone[rig.binding[i]]++; + expect(byBone).toEqual([14, 5, 5, 4, 4]); // root: торс+голова + }); + + it('каждая часть — связная колонна (6-связность)', () => { + const { model, rig } = mannequin(); + const g = decodeModel(model); + const cells = (bone: number) => { + const out: [number, number, number][] = []; + for (let i = 0; i < g.data.length; i++) + if (g.data[i] !== 0 && rig.binding[i] === bone) { + const x = i % g.sx, y = Math.floor(i / g.sx) % g.sy, z = Math.floor(i / (g.sx * g.sy)); + out.push([x, y, z]); + } + return out.sort((a, b) => a[1] - b[1]); + }; + for (const bone of [1, 2, 3, 4]) { + const col = cells(bone); + expect(col.length).toBeGreaterThan(3); + for (let k = 1; k < col.length; k++) { + expect(Math.abs(col[k][0] - col[k - 1][0]) + Math.abs(col[k][1] - col[k - 1][1]) + Math.abs(col[k][2] - col[k - 1][2])).toBe(1); + } + } + }); + + it('поворот ноги двигает ногу, но не торс (жёсткая привязка частей)', () => { + const { model, rig } = mannequin(); + const bindPosed = poseVoxels(rig, model, {}); + const swing = poseVoxels(rig, model, { legL: { rot: [0.6, 0, 0] } }); + for (const a of bindPosed) { + const b = swing.find((v) => v.index === a.index)!; + const moved = Math.abs(a.pos[0] - b.pos[0]) + Math.abs(a.pos[1] - b.pos[1]) + Math.abs(a.pos[2] - b.pos[2]); + const isLegL = rig.binding[a.index] === 1; + if (isLegL) expect(moved).toBeGreaterThan(0.1); + else expect(moved).toBe(0); + } + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/models/mannequin.ts b/v2/packages/engine/src/models/mannequin.ts new file mode 100644 index 0000000..64cf9d5 --- /dev/null +++ b/v2/packages/engine/src/models/mannequin.ts @@ -0,0 +1,65 @@ +/** + * Манекен — эталонная модель для рига и анимации (docs/plan.md, трек 6). + * Деревянный болванчик 5×11×3: ноги (P4), торс (R1), голова (P6), + * руки (R2 — ржавые, видны в махе). Части известны при генерации, поэтому + * привязка к костям передаётся явно (bindNearest здесь не нужен: торс + * рядом с плечами и Voronoi по pivot отнёс бы его к рукам). + */ +import { VoxelGrid } from '../voxel/grid'; +import { encodeModel } from './format'; +import type { VoxelModel } from './format'; +import { makeRig } from '../animation/rig'; +import type { VoxelRig } from '../animation/rig'; + +// Слоты арт-библии: как в генераторах деревьев/валунов +const TORSO = 4; // R1 — тёмное дерево +const LEG = 6; // P4 — камень (ноги-колонны) +const HEAD = 7; // P6 — свет +const ARM = 8; // R2 — ржавчина (махи рук заметны) + +export const MANNEQUIN_BONES = ['root', 'legL', 'legR', 'armL', 'armR'] as const; +export type MannequinBone = (typeof MANNEQUIN_BONES)[number]; + +export interface Mannequin { + model: VoxelModel; + rig: VoxelRig; +} + +/** Детерминированный манекен с ригой: root → legL/legR/armL/armR. */ +export function mannequin(): Mannequin { + const g = new VoxelGrid(5, 11, 3); + // привязка: индекс вокселя → индекс кости (root=0, legL=1, legR=2, armL=3, armR=4) + const binding = new Array(g.data.length).fill(-1); + const put = (x: number, y: number, z: number, slot: number, bone: number): void => { + g.set(x, y, z, slot); + binding[x + g.sx * (y + g.sy * z)] = bone; + }; + // ноги: колонны x=1 и x=3, y=0..4 + for (let y = 0; y <= 4; y++) { + put(1, y, 1, LEG, 1); + put(3, y, 1, LEG, 2); + } + // торс: x=1..3, y=5..8; голова: x=2, y=9..10 + for (let y = 5; y <= 8; y++) + for (let x = 1; x <= 3; x++) put(x, y, 1, TORSO, 0); + put(2, 9, 1, HEAD, 0); + put(2, 10, 1, HEAD, 0); + // руки: колонны x=0 и x=4, y=5..8 (свободно висят) + for (let y = 5; y <= 8; y++) { + put(0, y, 1, ARM, 3); + put(4, y, 1, ARM, 4); + } + const model = encodeModel(g, { 4: '#5c3b2e', 6: '#5b5b66', 7: '#94949e', 8: '#82503a' }); + const rig = makeRig( + [ + { name: 'root', parent: -1, pivot: [2.5, 0, 1.5] }, // земля, центр пят + { name: 'legL', parent: 0, pivot: [1.5, 5, 1.5] }, // бедро левой + { name: 'legR', parent: 0, pivot: [3.5, 5, 1.5] }, // бедро правой + { name: 'armL', parent: 0, pivot: [0.5, 9, 1.5] }, // плечо левой + { name: 'armR', parent: 0, pivot: [4.5, 9, 1.5] }, // плечо правой + ], + model, + binding, + ); + return { model, rig }; +} \ No newline at end of file diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts index 258dc8e..1146bca 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -9,6 +9,15 @@ */ import * as THREE from 'three'; import type { VoxelMesh } from '../voxel/mesher'; +import type { PosedVoxel } from '../animation/rig'; + +/** Облако деформированных вокселей (позы рига — не выровнены по сетке). */ +export interface VoxelCloud { + /** Заменить воксели облака (позиции, цвета, количество). */ + update(voxels: readonly (Pick)[]): void; + dispose(): void; + mesh: THREE.InstancedMesh; +} export interface VoxelRendererOptions { /** низкое разрешение рендера (пиксель-арт), по умолчанию 480×270 */ @@ -85,6 +94,51 @@ } /** + * Облако вокселей для поз анимации: instanced кубы 1×1×1 (ось-выровнены), + * позиция и цвет — на инстанс. Обновление позы — без пересборки геометрии. + */ + addVoxelCloud(voxels: readonly (Pick)[]): VoxelCloud { + const geo = new THREE.BoxGeometry(1, 1, 1); + const mat = new THREE.MeshLambertMaterial(); + const mesh = new THREE.InstancedMesh(geo, mat, Math.max(1, voxels.length)); + mesh.castShadow = true; + mesh.receiveShadow = true; + mesh.frustumCulled = false; // bbox у InstancedMesh не считается по инстансам + this.scene.add(mesh); + const cloud: VoxelCloud = { + mesh, + update: (list) => { + if (list.length > mesh.instanceMatrix.count) { + this.scene.remove(mesh); + geo.dispose(); + mat.dispose(); + const fresh = this.addVoxelCloud(list); + cloud.update = fresh.update; + cloud.dispose = fresh.dispose; + cloud.mesh = fresh.mesh; + return; + } + const m = new THREE.Matrix4(); + const c = new THREE.Color(); + list.forEach((v, i) => { + mesh.setMatrixAt(i, m.setPosition(v.pos[0], v.pos[1], v.pos[2])); + mesh.setColorAt(i, c.set(v.color)); + }); + mesh.count = list.length; + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; + }, + dispose: () => { + this.scene.remove(mesh); + geo.dispose(); + mat.dispose(); + }, + }; + cloud.update(voxels); + return cloud; + } + + /** * Солнце дня: k — 0 рассвет → 0.5 полдень → 1 закат. Азимут едет по * мировой горизонтали (восток→запад), высота — по синусу дня; цвет теплеет * у горизонта. Тени на земле ползут от k — это ловит скриншот-гейт.