diff --git a/docs/plan.md b/docs/plan.md index cea9708..995bc75 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -836,3 +836,15 @@ ступенькой: лицо до y21, шапка назад до z4. Ирвину тёплая кожа в тон седине сливалась в «череп». 6 гейтов игры + 11 демок зелёные, 297 тестов; визуальная итерация по `shot.mjs --at 16,28` + `crop-zoom.mjs`. +- **Трек 26** (2026-09-10, «гигант; натяните текстуру и сделайте меньше + вокселей»): деталь — текстурой, не геометрией. Формат моделей — слой + `tiles`/`tex`: тайль = пиксель-арт на грани вокселя (`VoxelTile`, тексель + = слот палитры — перекраска красит и текстуры), `tex` — id тайля на + воксель; валидация числовая. Рендер — `addVoxelCloud(voxels, tiles)`: + атлас-канвас (ячейка = 6 суб-граней тайля, 0 — белая), инстанс-атрибут + `aTileId` + шейдерная инъция (грань куба из нормали); воксель с тайлем + белый, цвет дают тексели. Манекен 14×24×8 → **10×16×4 (164 вокс)**: лицо + — арт 8×8 текселей (бахрома, глаза, рот) тайлями 2×2 на фасаде головы, + волосы — макушка/затылок; правило ракурса ~55° сохранено. Игра: BODY_SIZE + [5,16,5], stride 9.6; порог маха anim-гейта 1.0→0.8 (ноги вдвое короче). + 6 гейтов игры + 11 демок зелёные, 300 тестов. diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 82b3842..ba14826 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -76,10 +76,20 @@ - `src/render/voxelRenderer.ts` — three.js-бэкенд: ортокамера диметрии 2:1, dirLight + shadow map + hemisphere, низкое разрешение (480×270); `addVoxelCloud` — instanced-кубы для поз анимации (не выровнены по - сетке; обновление позы без пересборки геометрии); + сетке; обновление позы без пересборки геометрии); второй аргумент + `tiles` (`resolveTiles(model)`) включает текстурный слой: атлас-канвас + (строка ячеек = 6 суб-граней тайля, ячейка 0 белая), инстанс-атрибут + `aTileId` + шейдерная инъция (грань куба — из нормали, сэмпл атласа); + воксель с тайлем красится белым instanceColor (цвет дают тексели); - `src/math/rng.ts` — детерминированный Rng (mulberry32): next/int/range/chance/pick; - `src/models/format.ts` — формат модели (JSON: size + плотный base64 + слоты-палитра), encode/decode/validate (числовая валидация агентом); + текстурный слой (трек 26): `tiles` — тайли пиксель-арта на грани вокселя + (`VoxelTile {size 1..8, faces[6] +x,−x,+y,−y,+z,−z}`, тексель = слот + палитры — перекраска красит и текстуры) + `tex` (байт на воксель — id + тайля); `encodeModel(g, palette, texture?)`, `texIdAt`/`texBytes`/ + `resolveTiles` (texels→hex, вход рендера); валидация ловит tex без + tiles, size вне 1..8, тексель 0, слот вне палитры, битые ссылки tex; - `src/models/generators.ts` — процедурные генераторы: `treeModel` (варианты 0 круглая / 1 колонна / 2 ель), `boulderModel` (union сфер, свет на верхушках столбцов); (вид, сид) → VoxelModel, детерминировано; @@ -93,18 +103,21 @@ слотов модели на палитру сцены (точный hex или ближайший RGB, при равенстве — меньший слот); нужен после импорта .vox; - `src/models/mannequin.ts` — `mannequin(palette?)`: эталонная модель - 14×24×8 «путник» с ригой (root → legL/legR/armL/armR, pivot в - связочном пространстве); силуэт деталями (сапоги с мысками, ремень, - плащ, кисти, голова с глазами/носом и ступенькой черепа — фасад лица - до y21, шапка волос назад до z4, иначе с игрового ракурса ~55° шапка - перекрывает глаза); `MannequinPalette {skin?, hair?, torso?, legs?, - arms?, boots?, belt?, eyes?}` — дизайн персонажа параметром (форма не - зависит от палитры; глаза — отдельный тёмный слот, не от ремня); + 10×16×4 «путник» (~1.6 тайла, 164 вокс; трек 26 — меньше вокселей, + деталь на текстурах) с ригой (root → legL/legR/armL/armR): сапоги + с мысками, юбка плаща шире, пояс, плечи, кисти, шея, голова; лицо — + текстурой (арт 8×8 текселей: бахрома волос, глаза 2 текселя, рот — + тайли 2×2 на 16 вокселях фасада z3 головы), волосы — макушка и + затылок геометрией; над фасадом лица в той же z-колонке вокселей нет + (ракурс ~55°, урок трека 25); `MannequinPalette {skin?, hair?, torso?, + legs?, arms?, boots?, belt?, eyes?}` — дизайн параметром (тексели лица + ссылаются на те же слоты — перекраска красит и лицо); - `src/animation/mat.ts` — аффинная математика без three (Vec3/Mat3/Affine, порядок Эйлера X→Y→Z, rotateAround); чистая, тестируется без рендера; - `src/animation/rig.ts` — рига: `makeRig` (привязка явная или `bindNearest` по ближайшему pivot), `validateRig` (родители раньше детей, длина и - согласованность привязки), `poseVoxels(rig, model, pose)` — деформация; + согласованность привязки), `poseVoxels(rig, model, pose)` — деформация + (у textured-моделей тянет `tile` — id тайля вокселя); - `src/animation/clip.ts` — клипы: ключи {t, кость: rot/pos}, `samplePose` (линейный семплинг, заворот t для циклов), `validateClip` (границы, монотонность, замкнутость цикла), `footMinY` (контакт стопы); @@ -860,6 +873,38 @@ - Тулза `tools/probe-hero.mjs [port]` — одноразовая проба «где герой» (startGame + heroInfo до/после gotoArea). +## Трек 26 (2026-09-10): текстурный слой + маленький манекен 10×16×4 + +Фидбек: «гигант получается; можно просто натянуть текстуру и сделать его +меньше из небольшого количества вокселей». Деталь переезжает с геометрии +на текстуры: воксель может нести **тайль** — пиксель-арт на грани (2×2 +текселя у манекена ≈ 1.7px на экране при рендере 960×540/viewSize 80). + +- **Формат** (`format.ts`): `VoxelTile {size 1..8, faces[6]}` (грани в + порядке +x,−x,+y,−y,+z,−z, base64 size² байт-слотов, 0 запрещён) + + `VoxelModel.tiles`/`VoxelModel.tex` (id тайля на воксель). Тексель = + слот палитры — перекраска персонажа красит и лицо. Валидация числовая + (см. структуру); `encodeModel(g, palette, texture?)`. +- **Рендер** (`voxelRenderer.ts`): `addVoxelCloud(voxels, resolveTiles(model))` + — атлас-канвас одной строкой (ячейка = 6 суб-граней тайля, ячейка 0 + белая = плоский цвет), flipY канваса ставит низ арта на v=0; инстанс- + атрибут `aTileId`, грань куба выбирается в вершиннике из нормали + (vFace), fragment умножает diffuse на сэмпл атласа. Воксель с тайлем + красится белым (цвет дают тексели), без тайля — цветом слота. +- **Манекен v4**: 14×24×8 (498 вокс) → **10×16×4 (164 вокс)**: сапоги с + мысками, ноги-колонны 1×1, юбка плаща шире, пояс, грудь, плечи, рукава + с кистями, шея, голова 4×4×3. Лицо — арт 8×8 текселей (бахрома волос, + глаза 2 текселя, рот), тайли 2×2 на 16 вокселях фасада z3 головы; + волосы — макушка (y15) и затылок (z1) геометрией. Правило ракурса ~55° + сохранено: над фасадом лица в той же z-колонке вокселей нет. +- **Игра**: BODY_SIZE [5,16,5], stride 9.6 (каденция 2.19 цикла/с — ноги + 5 вокс), облака героя и NPC создаются с `resolveTiles(model)`. +- Гейты: **порог маха стопы в anim-гейте 1.0 → 0.8** — ноги 5 вокс против + 10 у трека 25, радиус качания вдвое короче (измеренный мах 0.91). + Все 6 гейтов игры + 11 демок зелёные, 300 тестов. Визуально — + `shot.mjs --at 16,28` + `crop-zoom.mjs`: лицо читается (бахрома/глаза/ + рот), герой ~1.6 тайла, палитры NPC (пояс Милы, седина Ирвина) на месте. + ## Рабочие привычки - Для задач из 3+ шагов веди список задач и иди автономно. diff --git a/v2/apps/demo/src/anim.ts b/v2/apps/demo/src/anim.ts index 899f0e5..476054a 100644 --- a/v2/apps/demo/src/anim.ts +++ b/v2/apps/demo/src/anim.ts @@ -7,7 +7,7 @@ */ import { VoxelRenderer, VoxelGrid, meshVoxels, mannequin, walkClip, samplePose, poseVoxels, - validateClip, footMinY, + validateClip, footMinY, resolveTiles, } from '@rpg/engine'; import type { VoxelPalette, VoxelCloud } from '@rpg/engine'; @@ -66,11 +66,12 @@ const [cx, cz] = cellCenter(col, row); const pose = samplePose(clip, (DURATION * i) / FRAMES); const posed = poseVoxels(rig, model, pose).map((v) => ({ - // манекен 14×24×8 — центр по XZ ячейки, на подставке (y=2) + // манекен 10×16×4 — центр по 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, + ...(v.tile ? { tile: v.tile } : {}), })); - clouds.push(renderer.addVoxelCloud(posed)); + clouds.push(renderer.addVoxelCloud(posed, resolveTiles(model))); } function stats(): { clouds: number; voxels: number } { diff --git a/v2/apps/demo/src/main.ts b/v2/apps/demo/src/main.ts index bad7ac7..2dc6931 100644 --- a/v2/apps/demo/src/main.ts +++ b/v2/apps/demo/src/main.ts @@ -8,7 +8,7 @@ import { VoxelWorld, VoxelRenderer, meshChunk, CHUNK_SX, ISO_YAW, treeModel, boulderModel, stampModel, parseVox, remapSlots, type Variant, - mannequin, walkClip, Character, GameLoop, screenMoveToWorld, + mannequin, walkClip, Character, GameLoop, screenMoveToWorld, resolveTiles, DialogueRunner, validateDialogue, nearestInteractable, Inventory, SaveManager, SceneManager, PlayerCombat, CombatWorld, moveCircleHor, PLAYER_COMBAT, @@ -140,11 +140,11 @@ speed: 4, strideLength: 3.2, // 4 вокс/с × 0.8 с цикла — шаг без скольжения стоп }); -const heroCloud = renderer.addVoxelCloud(hero.visual()); +const heroCloud = renderer.addVoxelCloud(hero.visual(), resolveTiles(MAN)); // ---- NPC: страж у дома (Character на скорости 0 — стоит в позе покоя) ---- const npc = new Character(MAN_RIG, MAN, WALK, [42, 47], { speed: 0 }); -renderer.addVoxelCloud(npc.visual()); +renderer.addVoxelCloud(npc.visual(), resolveTiles(MAN)); // ---- Бой: CombatWorld (в юнитах v1) + сгустки-вьюхи (облака) ---- // Боевые числа v1 — в юнитах (герой ≈ 1 юнит); в демке тело героя 3 вокселя, @@ -736,7 +736,7 @@ bodyRemove(); bodyChar = new Character(MAN_RIG, MAN, WALK, [x, z], { size: BODY_SIZE, spawnY: 6 }); bodyChar.body.vel = [vx, 0, vz]; - bodyCloud = renderer.addVoxelCloud(bodyChar.visual()); + bodyCloud = renderer.addVoxelCloud(bodyChar.visual(), resolveTiles(MAN)); return bodyInfo(); } /** Прогнать n шагов физики фиксированным dt; фаза walk-цикла растёт в движении. */ diff --git a/v2/apps/demo/tools/checks/anim.mjs b/v2/apps/demo/tools/checks/anim.mjs index 6b6d81b..ceca20f 100644 --- a/v2/apps/demo/tools/checks/anim.mjs +++ b/v2/apps/demo/tools/checks/anim.mjs @@ -78,10 +78,11 @@ const mid = ps[2], zero = ps[0]; check('ноги и руки в противофазе (i=2)', mid.legR === -mid.legL && mid.armR === -mid.armL, { legL: mid.legL, armL: mid.armL }); // Мах в i=2: маховая нога поднимает стопу высоко; у опорной ноги - // мысок сапога (торчит к +Z за пивот) в крайнем шаге скользит у земли + // мысок сапога (торчит к +Z за пивот) в крайнем шаге скользит у земли. + // Ноги 5 вокс (трек 26) — радиус качания вдвое короче, мах ~0.9 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 && - Math.max(mid.footMinL, mid.footMinR) > 1.0 && Math.min(mid.footMinL, mid.footMinR) > 0.25, + Math.max(mid.footMinL, mid.footMinR) > 0.8 && Math.min(mid.footMinL, mid.footMinR) > 0.25, { 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 && diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index 0a724f7..5b88d91 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -10,6 +10,7 @@ VoxelWorld, VoxelRenderer, meshChunk, ISO_YAW, mannequin, walkClip, Character, GameLoop, screenMoveToWorld, DialogueRunner, Inventory, CutsceneRunner, validateCutscenes, + resolveTiles, } from '@rpg/engine'; import type { VoxelCloud, DialogueGraph, DialogueView, @@ -115,14 +116,14 @@ arms: '#5f4a38', boots: '#3a3532', belt: '#8a6a3a', }); const WALK = walkClip({ duration: 0.8, legSwing: 0.55, armSwing: 0.4, bob: 0.4, sway: 0.25 }); -const BODY_SIZE: [number, number, number] = [5, 24, 5]; +const BODY_SIZE: [number, number, number] = [5, 16, 5]; const hero = new Character(MAN_RIG, MAN, WALK, [80, 120], { size: BODY_SIZE, speed: 21, // v1: 1.75 юнита/с × UNIT 10 + фидбек «быстрее» → 2.1 юнита/с - strideLength: 12.8, // темп v1: 1.64 цикла/с при 2.1 юнита/с (ноги длинные) + strideLength: 9.6, // темп v1 при ногах 5 вокс: 2.19 цикла/с (трек 24) }); -const heroCloud = renderer.addVoxelCloud(hero.visual()); +const heroCloud = renderer.addVoxelCloud(hero.visual(), resolveTiles(MAN)); // ---- Состояние сюжета, сумка: диалоги и квест-стадии пишут прямо сюда ---- const state = new GameState(); @@ -227,7 +228,7 @@ const c = tileCenter(def.tile.x, def.tile.y); const { model, rig } = mannequin(def.colors); // у каждого NPC свой дизайн const char = new Character(rig, model, WALK, [c.x, c.z], { - size: BODY_SIZE, speed: 0, strideLength: 12.8, + size: BODY_SIZE, speed: 0, strideLength: 9.6, }); char.body.pos = [c.x, GROUND_Y, c.z]; return { @@ -240,7 +241,7 @@ dialogueId: def.id, }, char, - cloud: renderer.addVoxelCloud(char.visual()), + cloud: renderer.addVoxelCloud(char.visual(), resolveTiles(char.model)), }; } diff --git a/v2/packages/engine/src/animation/rig.ts b/v2/packages/engine/src/animation/rig.ts index 2bef013..9a50ec2 100644 --- a/v2/packages/engine/src/animation/rig.ts +++ b/v2/packages/engine/src/animation/rig.ts @@ -8,7 +8,7 @@ * Деформация жёсткая на кость (кости не растягиваются — дистанции внутри * кости сохраняются по построению). */ -import { decodeModel } from '../models/format'; +import { decodeModel, texBytes } from '../models/format'; import type { VoxelModel } from '../models/format'; import { composeAffine, rotateAround, affinePoint } from './mat'; import type { Affine, Vec3 } from './mat'; @@ -37,6 +37,8 @@ color: string; /** Индекс вокселя в сетке модели (i = x + sx·(y + sy·z)) — для привязки к кости. */ index: number; + /** Id тайля текстур (0 = плоский цвет) — только у моделей со слоем tex. */ + tile?: number; } /** Привязка «ближайший pivot» для моделей без явной разметки частей. */ @@ -111,13 +113,15 @@ export function poseVoxels(rig: VoxelRig, model: VoxelModel, pose: Pose): PosedVoxel[] { const grid = decodeModel(model); const worlds = boneWorlds(rig, pose); + const tex = texBytes(model); 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 }); + const tile = tex ? tex[i] : 0; + out.push({ pos, color: model.palette[String(slot)], index: i, ...(tile ? { tile } : {}) }); } 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 40658be..db34033 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -25,8 +25,8 @@ export { VoxelRenderer } 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'; +export { encodeModel, decodeModel, validateModel, assertModelValid, texIdAt, texBytes, resolveTiles } from './models/format'; +export type { VoxelModel, VoxelTile, ModelTexture, ResolvedTile } from './models/format'; export { treeModel, boulderModel } from './models/generators'; export type { Variant } from './models/generators'; export { stampModel } from './models/stamp'; diff --git a/v2/packages/engine/src/models/__tests__/format.test.ts b/v2/packages/engine/src/models/__tests__/format.test.ts index a9142ce..adaf13d 100644 --- a/v2/packages/engine/src/models/__tests__/format.test.ts +++ b/v2/packages/engine/src/models/__tests__/format.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { VoxelGrid } from '../../voxel/grid'; -import { assertModelValid, decodeModel, encodeModel, validateModel } from '../format'; +import { assertModelValid, decodeModel, encodeModel, resolveTiles, texIdAt, texBytes, validateModel } from '../format'; +import type { ModelTexture, VoxelModel } from '../format'; /** Маленькая сетка-фабрика: 2×3×2 с одним цветным вокселем. */ function makeGrid(): VoxelGrid { @@ -68,4 +69,58 @@ it('decodeModel бросает на невалидной модели', () => { expect(() => decodeModel({ size: [2, 2, 2], palette: { 1: '#111111' }, data: 'AAAA' })).toThrow(); }); + + it('текстурный слой: roundtrip tex/tiles, texIdAt/texBytes/resolveTiles', () => { + const g = makeGrid(); + // тайль 1: грань +z — пиксели [слот 3, слот 1, слот 3, слот 3] + const px = (slots: number[]) => btoa(String.fromCharCode(...slots)); + const texData = new Uint8Array(12); + texData[9] = 1; // воксель (1,2,1) — тайль 1 + const texture: ModelTexture = { + tiles: { 1: { size: 2, faces: [px([1, 1, 1, 1]), px([1, 1, 1, 1]), px([1, 1, 1, 1]), px([1, 1, 1, 1]), px([3, 1, 3, 3]), px([1, 1, 1, 1])] } }, + tex: texData, + }; + const model = encodeModel(g, PALETTE, texture); + expect(validateModel(model)).toEqual([]); + expect(texIdAt(model, 9)).toBe(1); + expect(texIdAt(model, 0)).toBe(0); + expect(texBytes(model)![9]).toBe(1); + const resolved = resolveTiles(model); + expect(resolved[1]!.size).toBe(2); + expect(resolved[1]!.faces[4]).toEqual(['#626c4c', '#4a5340', '#626c4c', '#626c4c']); + // модель без текстур: texIdAt = 0, resolveTiles пуст + const plain = encodeModel(g, PALETTE); + expect(texIdAt(plain, 9)).toBe(0); + expect(resolveTiles(plain)).toEqual({}); + }); + + it('validate ловит мусор текстурного слоя', () => { + const g = makeGrid(); + const px = (slots: number[]) => btoa(String.fromCharCode(...slots)); + const faces6 = (s: number[]) => [px(s), px(s), px(s), px(s), px(s), px(s)]; + const base = encodeModel(g, PALETTE, { + tiles: { 1: { size: 2, faces: faces6([1, 1, 1, 1]) } }, + tex: new Uint8Array(12), + }); + // tex без tiles и наоборот + expect(validateModel({ ...base, tiles: undefined }).some((p) => p.includes('tiles отсутствуют'))).toBe(true); + const noTex = encodeModel(g, PALETTE); + expect(validateModel({ ...noTex, tiles: { 1: { size: 2, faces: faces6([1, 1, 1, 1]) } } }).some((p) => p.includes('tex отсутствует'))).toBe(true); + // плохой размер тайля и грань не той длины + expect(validateModel({ ...base, tiles: { 1: { size: 9, faces: faces6([1]) } } }).some((p) => p.includes('1..8'))).toBe(true); + expect(validateModel({ ...base, tiles: { 1: { size: 2, faces: [px([1]), px([1, 1, 1, 1]), px([1, 1, 1, 1]), px([1, 1, 1, 1]), px([1, 1, 1, 1]), px([1, 1, 1, 1])] } } }).some((p) => p.includes('длины'))).toBe(true); + // тексель 0 запрещён и слот не из палитры + expect(validateModel({ ...base, tiles: { 1: { size: 2, faces: faces6([0, 1, 1, 1]) } } }).some((p) => p.includes('запрещён'))).toBe(true); + expect(validateModel({ ...base, tiles: { 1: { size: 2, faces: faces6([7, 1, 1, 1]) } } }).some((p) => p.includes('не в палитре'))).toBe(true); + // tex ссылается на несуществующий тайль + const tex9 = new Uint8Array(12); + tex9[0] = 5; + expect(validateModel({ ...base, tex: btoa(String.fromCharCode(...tex9)) }).some((p) => p.includes('нет в tiles'))).toBe(true); + }); + + it('validate: валидная модель-тест без текстур не требует tex (регресс)', () => { + const m: VoxelModel = encodeModel(makeGrid(), PALETTE); + expect(m.tiles).toBeUndefined(); + expect(validateModel(m)).toEqual([]); + }); }); \ 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 index ad04ac4..fd36ef8 100644 --- a/v2/packages/engine/src/models/__tests__/mannequin.test.ts +++ b/v2/packages/engine/src/models/__tests__/mannequin.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { mannequin } from '../mannequin'; import { validateRig, poseVoxels } from '../../animation/rig'; -import { decodeModel } from '../format'; +import { decodeModel, resolveTiles, texIdAt } from '../format'; describe('mannequin (манекен + рига)', () => { it('детерминирован', () => { @@ -13,24 +13,37 @@ expect(validateRig(m.rig, m.model)).toEqual([]); }); - it('состав частей: ноги 44 ×2, руки 32 ×2, корень 358 (торс+голова+детали)', () => { + it('состав частей: ноги 6 ×2, руки 6 ×2, корень 140 (торс+голова+детали)', () => { const { model, rig } = mannequin(); const g = decodeModel(model); - expect(g.count()).toBe(498); // 44×2 ноги + 32×2 руки + 346 корень + expect(g.count()).toBe(164); // 6×2 ноги + 6×2 руки + 140 корень 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([346, 44, 44, 32, 32]); + expect(byBone).toEqual([140, 6, 6, 6, 6]); }); - it('детали силуэта на месте: глаза-акценты, нос к +Z, мыски сапог', () => { - const g = decodeModel(mannequin().model); + it('лицо — текстурой: тайли 2×2 на фасаде головы, глаза в арте, мыски сапог', () => { + const m = mannequin(); + const g = decodeModel(m.model); const at = (x: number, y: number, z: number) => g.data[x + g.sx * (y + g.sy * z)]; - expect(at(5, 21, 5)).toBe(10); // глаз (тёмный, слот EYE) на фасаде - expect(at(8, 21, 5)).toBe(10); - expect(at(6, 20, 6)).toBe(7); // нос (кожа) выступает к +Z - expect(at(3, 0, 6)).toBe(2); // мысок сапога - expect(at(6, 18, 3)).toBe(7); // шея + expect(at(3, 0, 3)).toBe(2); // мысок сапога к +Z + expect(at(4, 11, 2)).toBe(7); // шея + // все 16 вокселей фасада головы (z3, y12..15, x3..6) несут тайли + const texId = (x: number, y: number) => texIdAt(m.model, x + g.sx * (y + g.sy * 3)); + for (let y = 12; y <= 15; y++) + for (let x = 3; x <= 6; x++) expect(texId(x, y)).toBeGreaterThan(0); + // вне фасада тайлей нет + expect(texIdAt(m.model, 0)).toBe(0); + // 16 тайлей; тайль глаза (x3, y14 → id 5): нижний ряд арта — волос+глаз + const resolved = resolveTiles(m.model); + expect(Object.keys(resolved).length).toBe(16); + expect(resolved[5]!.size).toBe(2); + expect(resolved[5]!.faces[4]).toEqual(['#6e4a30', '#94949e', '#6e4a30', '#2a2624']); + // перекраска палитрой красит и текстуры + const tinted = mannequin({ skin: '#c9a882', eyes: '#402a1e' }); + const rt = resolveTiles(tinted.model); + expect(rt[5]!.faces[4]).toEqual(['#6e4a30', '#c9a882', '#6e4a30', '#402a1e']); }); it('палитра — параметр: слоты красятся, форма не меняется', () => { diff --git a/v2/packages/engine/src/models/format.ts b/v2/packages/engine/src/models/format.ts index fd6130f..967e8a4 100644 --- a/v2/packages/engine/src/models/format.ts +++ b/v2/packages/engine/src/models/format.ts @@ -8,6 +8,21 @@ */ import { VoxelGrid } from '../voxel/grid'; +/** + * Тайль пиксель-арта на грани вокселя: деталь меньше вокселя (лицо, пряди, + * пряжка) живёт текстурой, а не геометрией. Пиксель — слот палитры модели, + * поэтому перекраска персонажа (MannequinPalette) красит и текстуры. + */ +export interface VoxelTile { + /** Размер грани в текселях 1..8 (грань квадратная). */ + size: number; + /** + * 6 граней в порядке +x,−x,+y,−y,+z,−z; каждая — base64 из size² байт, + * байт = слот палитры (0 запрещён — прозрачных текселей нет). + */ + faces: string[]; +} + export interface VoxelModel { /** Размер по осям [sx, sy, sz], каждая > 0. */ size: [number, number, number]; @@ -15,20 +30,72 @@ palette: Record; /** base64 плотных данных: 1 байт на воксель, 0 = пусто. */ data: string; + /** Текстуры граней: ключ = id тайля 1..255. */ + tiles?: Record; + /** base64: 1 байт на воксель — id тайля (0 = плоский цвет), длина = объём. */ + tex?: string; } const MAX_DIM = 1024; // предел по оси — защита от мусора и OOM const MAX_VOXELS = 32 * 1024 * 1024; // ~32 МБ данных -/** Кодирует сетку в модель: палитра берётся из карты слот→hex. */ -export function encodeModel(grid: VoxelGrid, palette: Record): VoxelModel { +/** Текстурный слой для encodeModel: тайли + id тайля на воксель. */ +export interface ModelTexture { + tiles: Record; + /** id тайля на воксель (0 = плоский цвет), длина = объём сетки. */ + tex: Uint8Array; +} + +/** Кодирует сетку в модель: палитра из карты слот→hex, текстуры — опционально. */ +export function encodeModel(grid: VoxelGrid, palette: Record, texture?: ModelTexture): VoxelModel { const bytes = new Uint8Array(grid.sx * grid.sy * grid.sz); for (let i = 0; i < bytes.length; i++) bytes[i] = grid.data[i]; - return { + const model: VoxelModel = { size: [grid.sx, grid.sy, grid.sz], palette: { ...palette }, data: bytesToBase64(bytes), }; + if (texture) { + model.tiles = Object.fromEntries( + Object.entries(texture.tiles).map(([k, t]) => [k, { size: t.size, faces: [...t.faces] }]), + ); + model.tex = bytesToBase64(texture.tex); + } + return model; +} + +/** Id тайля вокселя i (0 = плоский цвет) — модели без текстур отвечают 0. */ +export function texIdAt(model: VoxelModel, i: number): number { + if (!model.tex) return 0; + return base64ToBytes(model.tex)[i] ?? 0; +} + +/** Раскладка tex целиком (для поз: один decode на весь проход) или null. */ +export function texBytes(model: VoxelModel): Uint8Array | null { + return model.tex ? base64ToBytes(model.tex) : null; +} + +/** Разрешённый тайль: пиксели граней в hex (по палитре модели) — вход рендера. */ +export interface ResolvedTile { + size: number; + /** faces[грань][пиксель] = hex. */ + faces: string[][]; +} + +/** Разворачивает тайли модели в hex-пиксели по её палитре (чистая функция). */ +export function resolveTiles(model: VoxelModel): Record { + const out: Record = {}; + if (!model.tiles) return out; + for (const [id, tile] of Object.entries(model.tiles)) { + out[Number(id)] = { + size: tile.size, + faces: tile.faces.map((face) => { + const bytes = base64ToBytes(face); + return Array.from(bytes, (b) => model.palette[String(b)] ?? '#000000'); + }), + }; + } + return out; } /** Декодирует модель в сетку. Бросает на невалидных данных (см. validateModel). */ @@ -95,6 +162,8 @@ } } } + + validateTexture(m, volume, palette, problems); return problems; } @@ -104,6 +173,67 @@ if (problems.length > 0) throw new Error(`модель невалидна: ${problems[0]}`); } +/** Валидация текстурного слоя: тайли (размер/грани/слоты) и раскладка tex. */ +function validateTexture( + m: Partial, volume: number, + palette: Record | undefined, problems: string[], +): void { + if (m.tex !== undefined && m.tiles === undefined) { + problems.push('tex задан, но tiles отсутствуют'); + return; + } + if (m.tiles !== undefined && m.tex === undefined) { + problems.push('tiles заданы, но tex отсутствует'); + return; + } + if (m.tiles === undefined) return; // текстурного слоя нет — норма + + const tiles = m.tiles as Record; + if (typeof tiles !== 'object' || Array.isArray(tiles)) { + problems.push('tiles должен быть объектом id→тайль'); + return; + } + for (const [key, tile] of Object.entries(tiles)) { + const id = Number(key); + if (!Number.isInteger(id) || id < 1 || id > 255) { + problems.push(`id тайля «${key}» вне 1..255`); + continue; + } + const t = tile as Partial | null; + if (!t || typeof t !== 'object' || !Number.isInteger(t.size) || t.size! < 1 || t.size! > 8) { + problems.push(`тайль ${id}: size должен быть целым 1..8`); + continue; + } + if (!Array.isArray(t.faces) || t.faces.length !== 6) { + problems.push(`тайль ${id}: faces должен быть массивом из 6 граней`); + continue; + } + t.faces.forEach((face, fi) => { + const bytes = safeBase64(face); + if (!bytes || bytes.length !== t.size! * t.size!) { + problems.push(`тайль ${id} грань ${fi}: base64 длины ${t.size!}² ожидается`); + return; + } + for (const b of bytes) { + if (b === 0) { problems.push(`тайль ${id} грань ${fi}: тексель 0 запрещён`); break; } + if (!(b in (palette ?? {}))) { problems.push(`тайль ${id} грань ${fi}: слот ${b} не в палитре`); break; } + } + }); + } + + const tex = safeBase64(m.tex); + if (!tex || tex.length !== volume) { + problems.push(`tex: данных ${tex?.length ?? 'нет'}, а нужно ${volume}`); + return; + } + for (const id of tex) { + if (id !== 0 && !(id in tiles)) { + problems.push(`tex ссылается на тайль ${id}, которого нет в tiles`); + break; + } + } +} + function bytesToBase64(bytes: Uint8Array): string { let bin = ''; for (let i = 0; i < bytes.length; i += 0x8000) { diff --git a/v2/packages/engine/src/models/mannequin.ts b/v2/packages/engine/src/models/mannequin.ts index 97f1248..08be949 100644 --- a/v2/packages/engine/src/models/mannequin.ts +++ b/v2/packages/engine/src/models/mannequin.ts @@ -1,29 +1,29 @@ /** - * Манекен — эталонная модель для рига и анимации (docs/plan.md, трек 6). - * Путник 14×24×8 (~2.4 тайла UNIT=10; фасад — сторона +Z: глаза, нос, - * мыски сапог). Силуэт собирается деталями, а не кубом: сапоги с мысками, - * штаны, ремень, плащ (расширяется книзу), рукава с кистями, шея, голова - * с глазами и волосами (по палитре — капюшоном). Палитра — параметр: - * у героя и NPC свои цвета (по умолчанию — деревянный болванчик). Части - * известны при генерации, поэтому привязка к костям передаётся явно - * (bindNearest здесь не нужен: торс рядом с плечами и Voronoi по pivot - * отнёс бы его к рукам). + * Манекен — эталонная модель для рига и анимации (docs/plan.md, трек 6/26). + * Путник 10×16×4 (~1.6 тайла, ~164 вокс — втрое меньше трека 25): деталя + * живёт ТЕКСТУРОЙ, а не геометрией (слой tiles/tex формата). Лицо — арт + * 8×8 текселей (4×4 вокселя фасада × тайли 2×2), волосы — бахрома в арте + * и шапка на затылке/макушке. Правило ракурса (трек 25): камера сверху + * ~55° — над фасадом лица (+Z) в той же z-колонке вокселей выше нет. + * Палитра — параметр: у героя и NPC свои цвета (тексели ссылаются на слоты + * палитры, перекраска красит и текстуры). Части известны при генерации — + * привязка к костям передаётся явно. */ import { VoxelGrid } from '../voxel/grid'; import { encodeModel } from './format'; -import type { VoxelModel } from './format'; +import type { VoxelModel, VoxelTile } from './format'; import { makeRig } from '../animation/rig'; import type { VoxelRig } from '../animation/rig'; -// Слоты модели (палитра — параметр mannequin()) +// Слоты модели (палитра — параметр mannequin()); тексели лица — те же слоты const TORSO = 4; // плащ const LEG = 6; // штаны const SKIN = 7; // лицо/кисти const ARM = 8; // рукава const BOOT = 2; // сапоги -const HAIR = 5; // волосы/капюшон +const HAIR = 5; // волосы const BELT = 9; // ремень -const EYE = 10; // глаза (тёмный, не зависит от ремня) +const EYE = 10; // глаза и рот (тёмный, не зависит от ремня) export const MANNEQUIN_BONES = ['root', 'legL', 'legR', 'armL', 'armR'] as const; export type MannequinBone = (typeof MANNEQUIN_BONES)[number]; @@ -32,7 +32,7 @@ export interface MannequinPalette { /** Лицо и кисти рук. */ skin?: string; - /** Волосы или капюшон. */ + /** Волосы. */ hair?: string; /** Торс / плащ. */ torso?: string; @@ -44,7 +44,7 @@ boots?: string; /** Ремень. */ belt?: string; - /** Глаза (по умолчанию тёмные — не зависят от ремня). */ + /** Глаза и рот (по умолчанию тёмные — не зависят от ремня). */ eyes?: string; } @@ -53,9 +53,34 @@ rig: VoxelRig; } +/** + * Арт лица 8×8 текселей, строки сверху вниз (фасад головы 4×4 вокселя, + * каждый несёт тайль 2×2 = блок 2×2 арта). Волосы — бахрома по верху + * и пряди по бокам, глаза 2 текселя, рот под ними. + */ +const FACE_ART: number[][] = [ + [HAIR, HAIR, HAIR, HAIR, HAIR, HAIR, HAIR, HAIR], + [HAIR, HAIR, HAIR, HAIR, HAIR, HAIR, HAIR, HAIR], + [HAIR, SKIN, SKIN, SKIN, SKIN, SKIN, SKIN, HAIR], + [HAIR, EYE, EYE, SKIN, SKIN, EYE, EYE, HAIR], + [SKIN, SKIN, SKIN, SKIN, SKIN, SKIN, SKIN, SKIN], + [SKIN, SKIN, SKIN, SKIN, SKIN, SKIN, SKIN, SKIN], + [SKIN, SKIN, SKIN, EYE, EYE, SKIN, SKIN, SKIN], + [SKIN, SKIN, SKIN, SKIN, SKIN, SKIN, SKIN, SKIN], +]; + +const b64 = (bytes: number[]): string => btoa(String.fromCharCode(...bytes)); + +/** Тайль лица для вокселя (dx,dy) фасада: +z — блок арта, прочие грани — слот вокселя. */ +function faceTile(slot: number, dx: number, dy: number): VoxelTile { + const flat = b64([slot, slot, slot, slot]); + const block = [0, 1].flatMap((r) => [0, 1].map((c) => FACE_ART[dy * 2 + r][dx * 2 + c])); + return { size: 2, faces: [flat, flat, flat, flat, b64(block), flat] }; +} + /** Детерминированный манекен с ригой: root → legL/legR/armL/armR. */ export function mannequin(palette: MannequinPalette = {}): Mannequin { - const g = new VoxelGrid(14, 24, 8); + const g = new VoxelGrid(10, 16, 4); // привязка: индекс вокселя → индекс кости (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 => { @@ -71,37 +96,37 @@ for (let z = z0; z <= z1; z++) for (let x = x0; x <= x1; x++) put(x, y, z, slot, bone); }; - // ноги: колонны 2×2 (x3..4 и x9..10, z3..4), y2..9; сапоги y0..1 с мыском к +Z - box(3, 4, 2, 9, 3, 4, LEG, 1); - box(9, 10, 2, 9, 3, 4, LEG, 2); - for (const [x0, bone] of [[3, 1], [9, 2]] as const) { - box(x0, x0 + 1, 0, 1, 3, 4, BOOT, bone); - box(x0, x0 + 1, 0, 0, 5, 6, BOOT, bone); // мысок + // ноги: колонны 1×1 (x3 и x6, z2), y1..4; сапоги y0 с мыском к +Z + box(3, 3, 1, 4, 2, 2, LEG, 1); + box(6, 6, 1, 4, 2, 2, LEG, 2); + put(3, 0, 2, BOOT, 1); put(3, 0, 3, BOOT, 1); + put(6, 0, 2, BOOT, 2); put(6, 0, 3, BOOT, 2); + // торс: юбка плаща y5..6 шире, пояс y7, грудь y8..9, плечи y10 + box(2, 7, 5, 6, 1, 3, TORSO, 0); + box(3, 6, 7, 7, 1, 3, BELT, 0); + box(3, 6, 8, 9, 1, 3, TORSO, 0); + box(2, 7, 10, 10, 1, 3, TORSO, 0); + // руки: рукава (x1 и x8, z2) y7..10, кисти (кожа) y5..6 + box(1, 1, 7, 10, 2, 2, ARM, 3); + box(8, 8, 7, 10, 2, 2, ARM, 4); + put(1, 5, 2, SKIN, 3); put(1, 6, 2, SKIN, 3); + put(8, 5, 2, SKIN, 4); put(8, 6, 2, SKIN, 4); + // шея и голова: y11 шея, голова y12..15; волосы — макушка (y15) и затылок (z1) + box(4, 5, 11, 11, 2, 2, SKIN, 0); + box(3, 6, 12, 15, 1, 3, SKIN, 0); + box(3, 6, 15, 15, 1, 3, HAIR, 0); + box(3, 6, 12, 14, 1, 1, HAIR, 0); + // фасад лица (+Z, z3): арт 8×8 текселей тайлями 2×2 + const tiles: Record = {}; + const tex = new Uint8Array(g.data.length); + for (let dy = 0; dy < 4; dy++) { + for (let dx = 0; dx < 4; dx++) { + const x = 3 + dx, y = 15 - dy; + const id = 1 + dy * 4 + dx; + tiles[id] = faceTile(g.get(x, y, 3), dx, dy); + tex[x + g.sx * (y + g.sy * 3)] = id; + } } - // торс: юбка плаща y10..12 шире, пояс y13, грудь y14..15, плечи y16..17 - box(3, 10, 10, 12, 2, 5, TORSO, 0); - box(4, 9, 13, 13, 2, 5, BELT, 0); - box(4, 9, 14, 15, 2, 5, TORSO, 0); - box(3, 10, 16, 17, 2, 5, TORSO, 0); - // руки: рукава 2×2 (x1..2 и x11..12, z3..4) y12..17, кисти (кожа) y10..11 - for (const [x0, bone] of [[1, 3], [11, 4]] as const) { - box(x0, x0 + 1, 12, 17, 3, 4, ARM, bone); - box(x0, x0 + 1, 10, 11, 3, 4, SKIN, bone); - } - // шея и голова: y18 шея, голова y19..23. Череп ступенькой: фасад лица - // (z5) только до y21, волосы-шапка назад до z4 — с игрового ракурса - // (камера сверху ~55°) над глазами не должно быть вокселей, иначе - // луч к ним проходит через шапку и лицо не читается - box(6, 7, 18, 18, 3, 4, SKIN, 0); - box(4, 9, 19, 23, 2, 4, SKIN, 0); - box(4, 9, 19, 21, 5, 5, SKIN, 0); - box(4, 9, 22, 23, 2, 4, HAIR, 0); - box(4, 9, 19, 21, 2, 2, HAIR, 0); - // лицо на фасаде (+Z): глаза, нос выступает к +Z - put(5, 21, 5, EYE, 0); - put(8, 21, 5, EYE, 0); - put(6, 20, 6, SKIN, 0); - put(7, 20, 6, SKIN, 0); const model = encodeModel(g, { 4: palette.torso ?? '#5c3b2e', 6: palette.legs ?? '#5b5b66', @@ -111,14 +136,14 @@ 5: palette.hair ?? '#6e4a30', 9: palette.belt ?? '#3a3438', 10: palette.eyes ?? '#2a2624', - }); + }, { tiles, tex }); const rig = makeRig( [ - { name: 'root', parent: -1, pivot: [6.5, 0, 4] }, // земля, центр пят - { name: 'legL', parent: 0, pivot: [3.5, 10, 4] }, // бедро левой - { name: 'legR', parent: 0, pivot: [9.5, 10, 4] }, // бедро правой - { name: 'armL', parent: 0, pivot: [1.5, 17, 4] }, // плечо левой - { name: 'armR', parent: 0, pivot: [11.5, 17, 4] }, // плечо правой + { name: 'root', parent: -1, pivot: [5, 0, 2.5] }, // земля, центр пят + { name: 'legL', parent: 0, pivot: [3.5, 5, 2.5] }, // бедро левой + { name: 'legR', parent: 0, pivot: [6.5, 5, 2.5] }, // бедро правой + { name: 'armL', parent: 0, pivot: [1.5, 11, 2.5] }, // плечо левой + { name: 'armR', parent: 0, pivot: [8.5, 11, 2.5] }, // плечо правой ], model, binding, diff --git a/v2/packages/engine/src/physics/__tests__/character.test.ts b/v2/packages/engine/src/physics/__tests__/character.test.ts index fe6c436..c9e1c5a 100644 --- a/v2/packages/engine/src/physics/__tests__/character.test.ts +++ b/v2/packages/engine/src/physics/__tests__/character.test.ts @@ -66,12 +66,12 @@ c.setMove([1, 0]); for (let i = 0; i < 30; i++) c.step(w, 1 / 60); const voxels = c.visual(); - expect(voxels.length).toBe(498); // все воксели манекена + expect(voxels.length).toBe(164); // все воксели манекена for (const v of voxels) { - expect(Math.abs(v.pos[0] - c.body.pos[0])).toBeLessThanOrEqual(8); // модель 14 шириной + expect(Math.abs(v.pos[0] - c.body.pos[0])).toBeLessThanOrEqual(8); // модель 10 шириной expect(v.pos[1]).toBeGreaterThanOrEqual(c.body.pos[1]); } const idle = makeChar(); - expect(idle.visual().length).toBe(498); + expect(idle.visual().length).toBe(164); }); }); \ No newline at end of file diff --git a/v2/packages/engine/src/physics/character.ts b/v2/packages/engine/src/physics/character.ts index 7d54db8..d05a73c 100644 --- a/v2/packages/engine/src/physics/character.ts +++ b/v2/packages/engine/src/physics/character.ts @@ -27,7 +27,7 @@ } /** Деформированный воксель позы для рендера (облако движка). */ -export type CharacterVoxel = Pick; +export type CharacterVoxel = Pick; export class Character { readonly body: Body; @@ -88,6 +88,7 @@ b.pos[2] - dx * s + dz * c, ] as Vec3, color: v.color, + ...(v.tile ? { tile: v.tile } : {}), }; }); } diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts index bfbc53d..e29c199 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -10,16 +10,20 @@ import * as THREE from 'three'; import type { VoxelMesh } from '../voxel/mesher'; import type { PosedVoxel } from '../animation/rig'; +import type { ResolvedTile } from '../models/format'; import { lerpColor } from './daynight'; /** Облако деформированных вокселей (позы рига — не выровнены по сетке). */ export interface VoxelCloud { - /** Заменить воксели облака (позиции, цвета, количество). */ - update(voxels: readonly (Pick)[]): void; + /** Заменить воксели облака (позиции, цвета, тайли — количество). */ + update(voxels: readonly Pick[]): void; dispose(): void; mesh: THREE.InstancedMesh; } +/** Текстуры граней облака: id тайля → разрешённые hex-пиксели (см. resolveTiles). */ +export type CloudTiles = Record; + /** Параметры точечного источника света (окна, очаг, лампа героя). */ export interface PointLightOptions { /** Позиция в мировых единицах. */ @@ -180,46 +184,11 @@ /** * Облако вокселей для поз анимации: instanced кубы 1×1×1 (ось-выровнены), * позиция и цвет — на инстанс. Обновление позы — без пересборки геометрии. + * tiles — текстуры граней (см. resolveTiles): воксель с tile>0 рисуется + * пиксель-артом тайля (цвет инстанса — белый), без tile — плоским цветом. */ - 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; + addVoxelCloud(voxels: readonly Pick[], tiles?: CloudTiles): VoxelCloud { + return createCloud(this.scene, voxels, tiles); } /** @@ -272,4 +241,125 @@ this.camera.position.copy(this.target).addScaledVector(dir, this.viewSize * 4); this.camera.lookAt(this.target); } +} + +/** + * Атлас тайлей: одна строка, ячейка тайля = 6 суб-граней подряд + * (+x,−x,+y,−y,+z,−z), ячейка 0 — белая (плоский цвет × instanceColor). + * Канвас рисуется сверху-вниз, flipY=true у CanvasTexture ставит низ канваса + * на v=0 — низ грани сэмплирует низ арта, текстура читается естественно. + */ +function buildTileAtlas(tiles: CloudTiles): { texture: THREE.CanvasTexture; texel: number; width: number } { + let texel = 1, maxId = 0; + for (const [id, t] of Object.entries(tiles)) { + texel = Math.max(texel, t.size); + maxId = Math.max(maxId, Number(id)); + } + const width = (maxId + 1) * 6 * texel; + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = texel; + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, 6 * texel, texel); + for (const [id, t] of Object.entries(tiles)) { + const k = texel / t.size; // тайли мельче texel растягиваются в ячейку + const hexToCss = (hex: string) => (hex.length === 4 ? '#' + [...hex.slice(1)].map((c) => c + c).join('') : hex); + t.faces.forEach((pixels, fi) => { + pixels.forEach((hex, px) => { + ctx.fillStyle = hexToCss(hex); + ctx.fillRect((Number(id) * 6 + fi) * texel + (px % t.size) * k, Math.floor(px / t.size) * k, k, k); + }); + }); + } + const texture = new THREE.CanvasTexture(canvas); + texture.magFilter = THREE.NearestFilter; + texture.minFilter = THREE.NearestFilter; + texture.generateMipmaps = false; + texture.colorSpace = THREE.SRGBColorSpace; + return { texture, texel, width }; +} + +/** Шейдерная инъция: uv сэмплинга атласа по грани куба (индекс из нормали). */ +function texturizeCloudMaterial(mat: THREE.MeshLambertMaterial, texel: number, width: number): void { + mat.onBeforeCompile = (shader) => { + shader.uniforms.uTexel = { value: texel }; + shader.uniforms.uAtlasWidth = { value: width }; + shader.vertexShader = shader.vertexShader + .replace('#include ', '#include \nattribute float aTileId;\nvarying float vTile;\nvarying float vFace;') + .replace('#include ', [ + '#include ', + 'vTile = aTileId;', + 'vFace = normal.x > 0.5 ? 0.0 : normal.x < -0.5 ? 1.0 : normal.y > 0.5 ? 2.0 : normal.y < -0.5 ? 3.0 : normal.z > 0.5 ? 4.0 : 5.0;', + ].join('\n')); + shader.fragmentShader = shader.fragmentShader + .replace('#include ', '#include \nvarying float vTile;\nvarying float vFace;\nuniform float uTexel;\nuniform float uAtlasWidth;') + .replace('#include ', [ + '#ifdef USE_MAP', + 'vec2 cell = vec2((vTile * 6.0 + vFace) + vMapUv.x, vMapUv.y) * uTexel;', + 'diffuseColor *= texture2D(map, cell / vec2(uAtlasWidth, uTexel));', + '#endif', + ].join('\n')); + }; +} + +/** Фабрика облака (отдельная функция — рекурсия при росте ёмкости). */ +function createCloud( + scene: THREE.Scene, + voxels: readonly Pick[], + tiles?: CloudTiles, +): VoxelCloud { + const textured = !!tiles && Object.keys(tiles).length > 0; + const geo = new THREE.BoxGeometry(1, 1, 1); + const mat = new THREE.MeshLambertMaterial(); + let atlas: { texture: THREE.CanvasTexture; texel: number; width: number } | null = null; + if (textured) { + atlas = buildTileAtlas(tiles!); + mat.map = atlas.texture; + texturizeCloudMaterial(mat, atlas.texel, atlas.width); + } + const mesh = new THREE.InstancedMesh(geo, mat, Math.max(1, voxels.length)); + mesh.castShadow = true; + mesh.receiveShadow = true; + mesh.frustumCulled = false; // bbox у InstancedMesh не считается по инстансам + const tileAttr = new THREE.InstancedBufferAttribute(new Float32Array(mesh.instanceMatrix.count), 1); + tileAttr.setUsage(THREE.DynamicDrawUsage); + geo.setAttribute('aTileId', tileAttr); + scene.add(mesh); + + const cloud: VoxelCloud = { + mesh, + update: (list) => { + if (list.length > mesh.instanceMatrix.count) { + scene.remove(mesh); + geo.dispose(); + mat.dispose(); + atlas?.texture.dispose(); + const fresh = createCloud(scene, list, tiles); + 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.tile ? '#ffffff' : v.color)); + tileAttr.array[i] = v.tile ?? 0; + }); + mesh.count = list.length; + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; + tileAttr.needsUpdate = true; + }, + dispose: () => { + scene.remove(mesh); + geo.dispose(); + mat.dispose(); + atlas?.texture.dispose(); + }, + }; + cloud.update(voxels); + return cloud; } \ No newline at end of file