diff --git a/docs/plan.md b/docs/plan.md index 16c4f00..f0e7f30 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -42,6 +42,7 @@ | 20b | Тени без 3D: блоб-тени актёров + направленные тени tall-объектов по солнцу | ✅ сделано (2026-09-09): движок — `render/shadowSim.ts` (чистая математика солнца `sunShadow`/`blobPlacement`, 8 тестов) + `render/shadows.ts` (ступенчатая текстура блоба, раскладка); игра — `GameShadows` (ground-слой между картой и актёрами, блобы первого ребёнком вьюхи, чистка уничтоженных), сценарий `checks/shadows.mjs` (утро/полдень/вечер/ночь, скриншоты). Позже по желанию: контактный AO у подножий | | 21 | Перепаковка репо под две версии движка: v1 (черновик + демо) → `v1/` целиком, корень — диспетчер команд (`--prefix v1`); фиксы тихих поломок v1 (тени «сидят на базе», RT-тьма выводится на экран); скелет v2: `v2/` workspace, `packages/engine` (`@rpg/engine` 2.0.0-alpha, three.js), маниалы CLAUDE.md двух уровней | ✅ сделано (2026-09-09): см. «Уроки черновика» и «Направление v2» ниже; верификация — check:fast + agent:check из корня зелёные | | 22 | Спайк v2: воксельный рендер — чистая математика вокселей (сетка, culled-меш, классический AO 0..3), `VoxelRenderer` на three.js (вершинные цвета, изо-ортокамера, dirLight+shadowMap), low-res 480×270 + nearest-апскейл, `apps/demo` со сценкой, скриншот-гейт через агента с первого дня (день/вечер, тени ползут) | ✅ сделано (2026-09-09): движок — `voxel/grid.ts` + `voxel/mesher.ts` (12 тестов) + `render/voxelRenderer.ts`; тулзы — `tools/agent-lib.mjs` (каркас v2) + `png.mjs` (RGB-скриншоты Chromium); демка — сценка в палитре арт-библии, мост `__agent.setSun/stats`; гейт `apps/demo/tools/checks/render.mjs` зелёный (тени ползут: 76k пикселей разницы утро/вечер, апскейл 2×2 без нарушений, 67 цветов). Грабля спайка: минус в Y изо-направления = камера под землёй — снапшоты зелёные, увидел только скриншотом (урок №2 подтверждён в первый же день) | +| 23 | v2 трек 2: чанковый мир — `Chunk` (16×H×16), `VoxelWorld` (словарь чанков, get/set через границы, dirty-трекинг), `meshRegion` (мешер области по функции доступа — шов чанков бесшовный, AO через границы), демка на мире 3×3 чанков, мост `setVoxel`, гейт: воксель поставлен → кадр изменился, AO на шве не рвётся | ✅ сделано (2026-09-09): движок — `world/chunk.ts` + `world/world.ts` (правка кромки помечает соседа) + `world/worldMesher.ts`; мешер обобщён до `meshRegion` (meshVoxels — обёртка); тест шва — меш 4 чанков ≡ меш одной сеткой (поз+AO попиксельно), 20 тестов v2. Демка — мир 3×3 чанков с ремешингом dirty, мост `setVoxel`/`chunkStats` (правка атомарна с ремешингом). Гейт 8/8: в т.ч. столбик на шве x=16 меняет кадр (1112 пикселей). Урок счёта: интерфейсная грань двух вокселей — два квада (12−2=10, не 11) | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 1b13738..a7e8dc4 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -49,16 +49,22 @@ - `packages/engine` (`@rpg/engine`) — ядро: - `src/voxel/grid.ts` — воксельная сетка (Uint8, 0 = пусто, иначе индекс палитры); - `src/voxel/mesher.ts` — culled-меш + классический AO 0..3 (схема 0fps, - флип диагонали), чистая математика, тестируется без рендера; + флип диагонали); `meshVoxels` для сетки, `meshRegion` — область по + функции доступа; чистая математика, тестируется без рендера; + - `src/world/chunk.ts` — чанк 16×H×16; `src/world/world.ts` — мир-словарь + чанков (get/set через границы, dirty-трекинг: правка боковой кромки + помечает соседа); `src/world/worldMesher.ts` — `meshChunk` через + `world.get`, шов чанков бесшовный (culled и AO через границы); - `src/render/voxelRenderer.ts` — three.js-бэкенд: ортокамера диметрии 2:1, dirLight + shadow map + hemisphere, низкое разрешение (480×270); - `tools/agent-lib.mjs` — браузерный каркас проверок (launchBrowser/ startDevServer/openGame/AgentClient/Checks), swiftshader в headless; - `tools/png.mjs` — PNG-кодек (colorType 6 и 2 — скриншоты Chromium RGB). -- `apps/demo` — демо-полигон: `src/main.ts` (сценка «пепельный луг» в палитре - арт-библии), `tools/checks/render.mjs` — скриншот-гейт: нет pageerror, меш - собран, тени ползут (кадры утро/вечер различаются), апскейл кратен 2×2, - картинка многокрасочная. Мост демки: `window.__agent.setSun(k)`/`stats()`. +- `apps/demo` — демо-полигон: `src/main.ts` (мир 3×3 чанков «пепельный луг», + ремешинг по dirty-чанкам), `tools/checks/render.mjs` — скриншот-гейт: нет + pageerror, меш собран, тени ползут (утро/вечер), апскейл кратен 2×2, + многокрасочность, 9 чанков, `setVoxel` на шве меняет кадр. + Мост демки: `window.__agent.setSun(k)`/`stats()`/`setVoxel(x,y,z,c)`/`chunkStats()`. ## Известные грабли (уже собранные) diff --git a/v2/apps/demo/src/main.ts b/v2/apps/demo/src/main.ts index 87e121a..25e69d9 100644 --- a/v2/apps/demo/src/main.ts +++ b/v2/apps/demo/src/main.ts @@ -1,9 +1,11 @@ /** - * Демо-полигон v2: воксельная сценка (пепельный луг) под скриншот-гейтом. - * Мир строится в воксельных единицах, проекция — только в VoxelRenderer. - * Палитра — слоты арт-библии v1 (docs/art-style.md); тёплый цвет — только жизнь. + * Демо-полигон v2: воксельный мир 3×3 чанков (пепельный луг) под скриншот-гейтом. + * Мир живёт в воксельных единицах, проекция — только в VoxelRenderer. + * Ремешинг по dirty-чанкам: setVoxel моста правит ровно затронутые чанки. + * Палитра — слоты арт-библии v1; тёплый цвет — только жизнь. */ -import { VoxelGrid, VoxelRenderer, meshVoxels } from '@rpg/engine'; +import { VoxelWorld, VoxelRenderer, meshChunk, CHUNK_SX } from '@rpg/engine'; +import * as THREE from 'three'; // Слоты палитры арт-библии: индексы сцены → hex const PALETTE = { @@ -21,79 +23,110 @@ }, }; -const S = 32; // сторона площадки +const CHUNKS = 3; // мир 3×3 чанков +const S = CHUNKS * CHUNK_SX; // 48 — сторона площадки -/** Сценка: земля, деревья, дом, валун, цветы. Возвращает меш для рендера. */ -function buildScene(): ReturnType { - const g = new VoxelGrid(S, 10, S); - // земля: два слоя, пятна светлой травы сверху +/** Сценка: земля, деревья, дом, валун, цветы — пишется прямо в мир. */ +function buildScene(world: VoxelWorld): void { for (let x = 0; x < S; x++) for (let z = 0; z < S; z++) { - g.set(x, 0, z, 2); + world.set(x, 0, z, 2); const patch = (x * 7 + z * 13) % 11 < 3; // детерминированные пятна - g.set(x, 1, z, patch ? 3 : 1); + world.set(x, 1, z, patch ? 3 : 1); } - for (const [tx, tz, h] of [[6, 8, 4], [24, 12, 5], [10, 25, 4], [26, 26, 4]] as const) { - tree(g, tx, tz, h); + // деревья: три обычных + одно на шве x=CHUNK_SX (проверка шва глазами) + for (const [tx, tz, h] of [[6, 8, 4], [24, 12, 5], [10, 25, 4], [CHUNK_SX, 20, 5]] as const) { + tree(world, tx, tz, h); } - house(g, 15, 6); - boulder(g, 22, 21); + house(world, 30, 6); + boulder(world, 22, 21); for (const [fx, fz] of [[9, 17], [13, 21], [20, 16], [27, 9]] as const) { - g.set(fx, 2, fz, 9); // цветок — единственный тёплый цвет + world.set(fx, 2, fz, 9); // цветок — единственный тёплый цвет } - return meshVoxels(g, PALETTE); } /** Дерево: ствол + двухслойная крона со светлым верхом. */ -function tree(g: VoxelGrid, x: number, z: number, h: number): void { - for (let y = 2; y < 2 + h; y++) g.set(x, y, z, 4); +function tree(world: VoxelWorld, x: number, z: number, h: number): void { + for (let y = 2; y < 2 + h; y++) world.set(x, y, z, 4); const top = 2 + h; for (let dy = 0; dy < 2; dy++) for (let dx = -1; dx <= 1; dx++) for (let dz = -1; dz <= 1; dz++) { - g.set(x + dx, top + dy, z + dz, dy === 0 ? 3 : 5); // низ кроны темнее + world.set(x + dx, top + dy, z + dz, dy === 0 ? 3 : 5); // низ кроны темнее } - g.set(x, top + 2, z, 5); + world.set(x, top + 2, z, 5); } -/** Дом: стены P4 со светлыми пилястрами, двускатная крыша R2. */ -function house(g: VoxelGrid, x: number, z: number): void { +/** Дом: стены P4 со светлыми пилястрами, ступенчатая крыша R2. */ +function house(world: VoxelWorld, x: number, z: number): void { const w = 6, d = 5, hWall = 3; for (let i = 0; i < w; i++) for (let j = 0; j < d; j++) { const wall = i === 0 || j === 0 || i === w - 1 || j === d - 1; for (let y = 2; y < 2 + hWall; y++) { - if (wall) g.set(x + i, y, z + j, (i + j) % 3 === 0 ? 7 : 6); - else g.set(x + i, y, z + j, 10); // интерьер темнее (виден сквозь проёмы) + if (wall) world.set(x + i, y, z + j, (i + j) % 3 === 0 ? 7 : 6); + else world.set(x + i, y, z + j, 10); // интерьер темнее } } // дверной проём с юга - for (let y = 2; y < 4; y++) g.set(x + 3, y, z, 0); + for (let y = 2; y < 4; y++) world.set(x + 3, y, z, 0); // крыша: три сужающиеся ступени (скат вверх к коньку) for (let t = 0; t < 3; t++) { const j0 = z + t, j1 = z + d - 1 - t; for (let i = 0; i < w; i++) - for (let j = j0; j <= j1; j++) g.set(x + i, 2 + hWall + t, j, t === 2 ? 4 : 8); + for (let j = j0; j <= j1; j++) world.set(x + i, 2 + hWall + t, j, t === 2 ? 4 : 8); } } /** Валун: неровная глыба, свет сверху. */ -function boulder(g: VoxelGrid, x: number, z: number): void { +function boulder(world: VoxelWorld, x: number, z: number): void { const blobs = [[0, 0, 0], [1, 0, 0], [0, 0, 1], [1, 0, 1], [0, 1, 0], [1, 1, 0]]; - for (const [dx, dy, dz] of blobs) g.set(x + dx, 2 + dy, z + dz, dy ? 7 : 6); + for (const [dx, dy, dz] of blobs) world.set(x + dx, 2 + dy, z + dz, dy ? 7 : 6); } -const mesh = buildScene(); -const canvas = document.getElementById('scene') as HTMLCanvasElement; -const renderer = new VoxelRenderer(canvas, { target: [S / 2, 2, S / 2], viewSize: 15 }); -renderer.addMesh(mesh); +const world = new VoxelWorld(24); +buildScene(world); -// Агентный мост (только DEV-поведение): солнце + статистика меша +const canvas = document.getElementById('scene') as HTMLCanvasElement; +const renderer = new VoxelRenderer(canvas, { target: [S / 2, 2, S / 2], viewSize: 17 }); + +// Меш на чанк; ремешинг только dirty-чанков +const chunkMeshes = new Map(); +function remeshDirty(): void { + for (const key of world.takeDirty()) { + const [cx, cz] = key.split(',').map(Number); + const old = chunkMeshes.get(key); + if (old) { + renderer.scene.remove(old); + old.geometry.dispose(); + } + const mesh = meshChunk(world, cx, cz, PALETTE); + if (mesh.triangleCount > 0) chunkMeshes.set(key, renderer.addMesh(mesh)); + else chunkMeshes.delete(key); + } +} +remeshDirty(); + +// Агентный мост (DEV): солнце, статистика, правка мира атомарным ремешингом let sunK = 0.5; (window as unknown as Record).__agent = { setSun(k: number): void { sunK = k; renderer.setSun(k); }, + setVoxel(x: number, y: number, z: number, color: number): void { + world.set(x, y, z, color); + remeshDirty(); + }, stats(): { vertices: number; triangles: number; sun: number } { - return { vertices: mesh.vertexCount, triangles: mesh.triangleCount, sun: sunK }; + let vertices = 0, triangles = 0; + const mesh = [...chunkMeshes.values()]; + for (const m of mesh) { + const count = m.geometry.getAttribute('position').count; + vertices += count; + triangles += (m.geometry.getIndex()?.count ?? 0) / 3; + } + return { vertices, triangles, sun: sunK }; + }, + chunkStats(): { chunks: number; meshes: number } { + return { chunks: world.chunkCount, meshes: chunkMeshes.size }; }, }; diff --git a/v2/apps/demo/tools/checks/render.mjs b/v2/apps/demo/tools/checks/render.mjs index 9007789..891b09e 100644 --- a/v2/apps/demo/tools/checks/render.mjs +++ b/v2/apps/demo/tools/checks/render.mjs @@ -53,6 +53,22 @@ const colors = distinctColors(morning.rgba); check('картинка многокрасочная (палитра × AO × свет)', colors > 24, { colors }); + // Мир чанковый: земля 48×48 заполняет 3×3 чанков + const cs = await client.call('chunkStats'); + check('мир из 9 чанков, меш у всех', cs.chunks === 9 && cs.meshes === 9, cs); + + // Правка мира на шве чанков x=16: столбик в мир → кадр изменился + await client.call('setSun', 0.5); + const before = decodePng(readFileSync(await client.screenshot('/tmp/v2_render_before_edit.png'))); + await client.call('setVoxel', 15, 2, 10, 6); + await client.call('setVoxel', 16, 2, 10, 6); + await client.call('setVoxel', 16, 3, 10, 6); + const after = decodePng(readFileSync(await client.screenshot('/tmp/v2_render_after_edit.png'))); + const editDiff = countDiff(before.rgba, after.rgba); + check('setVoxel на шве чанков меняет кадр', editDiff > 200, { editDiff }); + const cs2 = await client.call('chunkStats'); + check('dirty-чанки после правки схлынули', cs2.chunks === 9 && cs2.meshes === 9, cs2); + report.ok = report.checks.every((c) => c.ok); } finally { await browser.close(); diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index e288f31..87ee760 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -17,7 +17,10 @@ export { VoxelGrid, hexToRgb } from './voxel/grid'; export type { VoxelPalette } from './voxel/grid'; -export { meshVoxels } from './voxel/mesher'; -export type { VoxelMesh, MeshOptions } from './voxel/mesher'; +export { meshVoxels, meshRegion } from './voxel/mesher'; +export type { VoxelMesh, MeshOptions, VoxelAt, RegionBounds } from './voxel/mesher'; +export { VoxelWorld } from './world/world'; +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'; \ No newline at end of file diff --git a/v2/packages/engine/src/voxel/mesher.ts b/v2/packages/engine/src/voxel/mesher.ts index fcf2286..abcd8b2 100644 --- a/v2/packages/engine/src/voxel/mesher.ts +++ b/v2/packages/engine/src/voxel/mesher.ts @@ -5,12 +5,17 @@ * На каждую вершину грани — классический воксельный AO (0..3) по трём соседям * (side1/side2/corner, схема 0fps) с флипом диагонали квадроугольника против * анизотропных артефактов. AO запекается в вершинные цвета. + * + * meshRegion мешает область по функции доступа `at(x,y,z)` — соседей за + * границей области он видит через ту же функцию, поэтому шов чанков + * бесшовный: culled и AO не рвутся между чанками (world-мешер вызывает + * meshRegion с world.get). */ -import { VoxelGrid, hexToRgb } from './grid'; import type { VoxelPalette } from './grid'; +import { VoxelGrid, hexToRgb } from './grid'; export interface VoxelMesh { - /** xyz по тройкам, в единицах вокселей (углы 0..1 локально) */ + /** xyz по тройкам, в единицах вокселей */ positions: number[]; /** нормали граней (для dirLight и теней) */ normals: number[]; @@ -29,6 +34,15 @@ aoLevels?: [number, number, number, number]; } +/** Функция доступа к цвету вокселя в мировых координатах (0 — пусто). */ +export type VoxelAt = (x: number, y: number, z: number) => number; + +/** Полуинтервалы области мешинга: x∈[x0,x1), y∈[y0,y1), z∈[z0,z1). */ +export interface RegionBounds { + x0: number; y0: number; z0: number; + x1: number; y1: number; z1: number; +} + const AO_LEVELS_DEFAULT: [number, number, number, number] = [0.45, 0.65, 0.85, 1.0]; /** Классический AO по трём соседям (1 — занято, 0 — пусто). */ @@ -48,18 +62,24 @@ const ORDER_POS: Array<[number, number]> = [[0, 0], [1, 0], [1, 1], [0, 1]]; const ORDER_NEG: Array<[number, number]> = [[0, 0], [0, 1], [1, 1], [1, 0]]; -/** - * Строит culled-меш сетки. Сосед вне сетки считается пустым (границы мира - * видны), поэтому открытые срезы не исчезают. - */ +/** Меш сетки целиком (обёртка над meshRegion). */ export function meshVoxels(grid: VoxelGrid, palette: VoxelPalette, opts: MeshOptions = {}): VoxelMesh { + return meshRegion((x, y, z) => grid.get(x, y, z), + { x0: 0, y0: 0, z0: 0, x1: grid.sx, y1: grid.sy, z1: grid.sz }, palette, opts); +} + +/** + * Меш области по функции доступа. Сосед вне сетки считается пустым (границы + * мира видны), поэтому открытые срезы не исчезают. + */ +export function meshRegion(at: VoxelAt, bounds: RegionBounds, palette: VoxelPalette, opts: MeshOptions = {}): VoxelMesh { const levels = opts.aoLevels ?? AO_LEVELS_DEFAULT; const rgbCache = new Map(); const rgb = (color: number): [number, number, number] => { let c = rgbCache.get(color); if (!c) { const hex = palette.colors[color]; - if (!hex) throw new Error(`meshVoxels: цвет ${color} не задан в палитре`); + if (!hex) throw new Error(`meshRegion: цвет ${color} не задан в палитре`); c = hexToRgb(hex); rgbCache.set(color, c); } @@ -67,12 +87,12 @@ }; const pos: number[] = [], nor: number[] = [], col: number[] = [], idx: number[] = [], ao: number[] = []; - const solid = (x: number, y: number, z: number): number => (grid.get(x, y, z) !== 0 ? 1 : 0); + const solid = (x: number, y: number, z: number): number => (at(x, y, z) !== 0 ? 1 : 0); - for (let z = 0; z < grid.sz; z++) - for (let y = 0; y < grid.sy; y++) - for (let x = 0; x < grid.sx; x++) { - const color = grid.get(x, y, z); + for (let z = bounds.z0; z < bounds.z1; z++) + for (let y = bounds.y0; y < bounds.y1; y++) + for (let x = bounds.x0; x < bounds.x1; x++) { + const color = at(x, y, z); if (color === 0) continue; emitVoxelFaces(x, y, z, color, rgb, levels, solid, pos, nor, col, idx, ao); } diff --git a/v2/packages/engine/src/world/__tests__/world.test.ts b/v2/packages/engine/src/world/__tests__/world.test.ts new file mode 100644 index 0000000..683daa1 --- /dev/null +++ b/v2/packages/engine/src/world/__tests__/world.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { Chunk, CHUNK_SX, CHUNK_SZ } from '../chunk'; +import { VoxelWorld } from '../world'; +import { meshChunk } from '../worldMesher'; +import { meshVoxels } from '../../voxel/mesher'; +import { VoxelGrid } from '../../voxel/grid'; + +const PALETTE = { colors: { 1: '#808080', 2: '#00ff00' } }; + +describe('Chunk', () => { + it('локальные get/set и границы', () => { + const c = new Chunk(2, 3, 8); + c.set(15, 7, 15, 5); + expect(c.get(15, 7, 15)).toBe(5); + expect(c.get(16, 7, 15)).toBe(0); + expect(c.get(15, 8, 15)).toBe(0); + expect(() => c.set(0, 0, 0, 300)).toThrow(); + }); +}); + +describe('VoxelWorld', () => { + it('get/set через границы чанков (отрицательные тоже)', () => { + const w = new VoxelWorld(32); + w.set(-1, 5, -1, 1); // чанк (-1,-1) + w.set(CHUNK_SX, 5, 0, 2); // чанк (1,0) + expect(w.get(-1, 5, -1)).toBe(1); + expect(w.get(CHUNK_SX, 5, 0)).toBe(2); + expect(w.get(CHUNK_SX - 1, 5, 0)).toBe(0); + expect(w.chunkCount).toBe(2); + }); + + it('нет чанка — пусто; set вне высоты — false', () => { + const w = new VoxelWorld(8); + expect(w.get(100, 3, 100)).toBe(0); + expect(w.set(0, 8, 0, 1)).toBe(false); + expect(w.set(0, -1, 0, 1)).toBe(false); + expect(w.chunkCount).toBe(0); + }); + + it('dirty: изменение на границе помечает соседа', () => { + const w = new VoxelWorld(32); + w.set(0, 1, 0, 1); // чанк (0,0), угол: соседи -1,0 и 0,-1 + w.set(CHUNK_SX - 1, 1, 0, 1); // восточная кромка чанка (0,0) + w.set(CHUNK_SX, 1, 0, 1); // чанк (1,0) — сосед создаётся + const dirty = w.takeDirty(); + expect(dirty).toContain('0,0'); + expect(dirty).toContain('1,0'); + expect(dirty).toContain('-1,0'); + expect(w.takeDirty()).toEqual([]); // забрали — очистилось + }); + + it('изменение в глубине чанка не трогает соседей', () => { + const w = new VoxelWorld(32); + w.set(CHUNK_SX + 5, 1, CHUNK_SZ + 5, 1); + expect(w.dirtyChunks()).toEqual(['1,1']); + }); +}); + +describe('шов чанков в мешере', () => { + /** Фигура через стык 4 чанков: гребень вдоль x по линии z=CHUNK_SZ. */ + function makeWorld(): VoxelWorld { + const w = new VoxelWorld(6); + for (let x = 0; x < CHUNK_SX * 2; x++) { + w.set(x, 1, CHUNK_SZ - 1, 1); + w.set(x, 1, CHUNK_SZ, 2); + w.set(x, 2, CHUNK_SZ, 1); + } + return w; + } + + const keyOf = (m: { positions: number[]; ao: number[] }): string => { + const rows: string[] = []; + for (let v = 0; v < m.ao.length; v++) { + rows.push(`${m.positions[v * 3]},${m.positions[v * 3 + 1]},${m.positions[v * 3 + 2]}|${m.ao[v]}`); + } + return rows.sort().join('\n'); + }; + + it('меш 4 чанков покрывает ту же геометрию, что одна большая сетка (без шва)', () => { + const w = makeWorld(); + const parts = [meshChunk(w, 0, 0, PALETTE), meshChunk(w, 1, 0, PALETTE), meshChunk(w, 0, 1, PALETTE), meshChunk(w, 1, 1, PALETTE)]; + const joined = keyOf({ positions: parts.flatMap((p) => p.positions), ao: parts.flatMap((p) => p.ao) }); + // эталон: та же фигура одной сеткой + const g = new VoxelGrid(CHUNK_SX * 2, 6, CHUNK_SZ * 2); + for (let x = 0; x < CHUNK_SX * 2; x++) { + g.set(x, 1, CHUNK_SZ - 1, 1); + g.set(x, 1, CHUNK_SZ, 2); + g.set(x, 2, CHUNK_SZ, 1); + } + const single = meshVoxels(g, PALETTE); + expect(joined).toBe(keyOf({ positions: single.positions, ao: single.ao })); + }); + + it('общая грань вокселей через границу чанков срезана', () => { + const w = new VoxelWorld(4); + w.set(CHUNK_SX - 1, 1, 0, 1); + w.set(CHUNK_SX, 1, 0, 1); + const a = meshChunk(w, 0, 0, PALETTE); + const b = meshChunk(w, 1, 0, PALETTE); + // 2 вокселя × 6 граней − интерфейсная пара (у каждого свой квад) = 10 граней + expect((a.vertexCount + b.vertexCount) / 4).toBe(10); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/world/chunk.ts b/v2/packages/engine/src/world/chunk.ts new file mode 100644 index 0000000..72a4210 --- /dev/null +++ b/v2/packages/engine/src/world/chunk.ts @@ -0,0 +1,51 @@ +/** + * Чанк воксельного мира — колонна 16×H×16. Чистая математика (урок №3). + * Локальные координаты: x,z в 0..15, y в 0..H-1; значение — индекс палитры + * (0 — пустота), как в VoxelGrid. + */ +export const CHUNK_SX = 16; +export const CHUNK_SZ = 16; + +export class Chunk { + readonly cx: number; + readonly cz: number; + readonly sy: number; + readonly data: Uint8Array; + + constructor(cx: number, cz: number, sy = 64) { + if (sy <= 0) throw new Error(`Chunk: высота ${sy} должна быть положительной`); + this.cx = cx; + this.cz = cz; + this.sy = sy; + this.data = new Uint8Array(CHUNK_SX * sy * CHUNK_SZ); + } + + inside(lx: number, y: number, lz: number): boolean { + return lx >= 0 && lz >= 0 && y >= 0 && lx < CHUNK_SX && lz < CHUNK_SZ && y < this.sy; + } + + idx(lx: number, y: number, lz: number): number { + return lx + CHUNK_SX * (y + this.sy * lz); + } + + get(lx: number, y: number, lz: number): number { + if (!this.inside(lx, y, lz)) return 0; + return this.data[this.idx(lx, y, lz)]; + } + + set(lx: number, y: number, lz: number, color: number): void { + if (!this.inside(lx, y, lz)) return; + if (color < 0 || color > 255) throw new Error(`Chunk.set: цвет ${color} вне 0..255`); + this.data[this.idx(lx, y, lz)] = color; + } + + isEmpty(lx: number, y: number, lz: number): boolean { + return this.get(lx, y, lz) === 0; + } + + count(): number { + let n = 0; + for (let i = 0; i < this.data.length; i++) if (this.data[i] !== 0) n++; + return n; + } +} \ No newline at end of file diff --git a/v2/packages/engine/src/world/world.ts b/v2/packages/engine/src/world/world.ts new file mode 100644 index 0000000..7ec1ce0 --- /dev/null +++ b/v2/packages/engine/src/world/world.ts @@ -0,0 +1,93 @@ +/** + * VoxelWorld — мир как словарь чанков. Мировые координаты целые (1 = размер + * вокселя), проекция — только на границе рендера (урок №1). + * + * Dirty-трекинг: set помечает чанк изменённым; изменение на боковой границе + * чанка помечает и соседа — иначе culled/AO-шов между чанками устареет. + */ +import { Chunk, CHUNK_SX, CHUNK_SZ } from './chunk'; + +export class VoxelWorld { + readonly chunkHeight: number; + private readonly chunks = new Map(); + private readonly dirty = new Set(); + + constructor(chunkHeight = 64) { + this.chunkHeight = chunkHeight; + } + + /** Ключ чанка по мировым координатам (и по чанковым — тоже работает). */ + static key(cx: number, cz: number): string { + return `${cx},${cz}`; + } + + chunkCoords(x: number, z: number): { cx: number; cz: number } { + return { cx: Math.floor(x / CHUNK_SX), cz: Math.floor(z / CHUNK_SZ) }; + } + + /** Чанк по чанковым координатам (без создания). */ + chunkAt(cx: number, cz: number): Chunk | undefined { + return this.chunks.get(VoxelWorld.key(cx, cz)); + } + + /** Чанк с созданием при отсутствии. */ + private ensureChunk(cx: number, cz: number): Chunk { + const key = VoxelWorld.key(cx, cz); + let c = this.chunks.get(key); + if (!c) { + c = new Chunk(cx, cz, this.chunkHeight); + this.chunks.set(key, c); + this.dirty.add(key); + } + return c; + } + + /** Цвет вокселя в мире (0 — пусто; несуществующий чанк пуст). */ + get(x: number, y: number, z: number): number { + const { cx, cz } = this.chunkCoords(x, z); + const c = this.chunkAt(cx, cz); + if (!c) return 0; + return c.get(x - cx * CHUNK_SX, y, z - cz * CHUNK_SZ); + } + + /** + * Поставить воксель (0 — стереть); чанк создаётся при необходимости. + * Возвращает false, если координата вне вертикали мира (y вне 0..H-1). + */ + set(x: number, y: number, z: number, color: number): boolean { + if (y < 0 || y >= this.chunkHeight) return false; + const { cx, cz } = this.chunkCoords(x, z); + const c = this.ensureChunk(cx, cz); + const lx = x - cx * CHUNK_SX, lz = z - cz * CHUNK_SZ; + c.set(lx, y, lz, color); + this.dirty.add(VoxelWorld.key(cx, cz)); + // боковая граница — соседний чанк тоже меняет меш (culled/AO) + if (lx === 0) this.dirty.add(VoxelWorld.key(cx - 1, cz)); + if (lx === CHUNK_SX - 1) this.dirty.add(VoxelWorld.key(cx + 1, cz)); + if (lz === 0) this.dirty.add(VoxelWorld.key(cx, cz - 1)); + if (lz === CHUNK_SZ - 1) this.dirty.add(VoxelWorld.key(cx, cz + 1)); + return true; + } + + /** Список изменённых чанков (ключи «cx,cz») без очистки. */ + dirtyChunks(): string[] { + return [...this.dirty]; + } + + /** Забрать и очистить список изменённых чанков (для ремешинга). */ + takeDirty(): string[] { + const out = this.dirtyChunks(); + this.dirty.clear(); + return out; + } + + /** Число существующих чанков. */ + get chunkCount(): number { + return this.chunks.size; + } + + /** Все ключи чанков (стабильный порядок сортировки). */ + chunkKeys(): string[] { + return [...this.chunks.keys()].sort(); + } +} \ No newline at end of file diff --git a/v2/packages/engine/src/world/worldMesher.ts b/v2/packages/engine/src/world/worldMesher.ts new file mode 100644 index 0000000..66c48ff --- /dev/null +++ b/v2/packages/engine/src/world/worldMesher.ts @@ -0,0 +1,23 @@ +/** + * Мешер мира: чанк мешается через world.get, поэтому culled и AO на швах + * между чанками бесшовны (соседей за границей видно). Чистая математика. + */ +import type { VoxelPalette } from '../voxel/grid'; +import { meshRegion } from '../voxel/mesher'; +import type { MeshOptions, RegionBounds, VoxelMesh } from '../voxel/mesher'; +import type { VoxelWorld } from './world'; +import { CHUNK_SX, CHUNK_SZ } from './chunk'; + +/** Полуинтервал области чанка в мировых координатах. */ +export function chunkBounds(cx: number, cz: number, sy: number): RegionBounds { + return { + x0: cx * CHUNK_SX, x1: (cx + 1) * CHUNK_SX, + y0: 0, y1: sy, + z0: cz * CHUNK_SZ, z1: (cz + 1) * CHUNK_SZ, + }; +} + +/** Меш одного чанка; соседи берутся из мира (шов бесшовный). */ +export function meshChunk(world: VoxelWorld, cx: number, cz: number, palette: VoxelPalette, opts: MeshOptions = {}): VoxelMesh { + return meshRegion((x, y, z) => world.get(x, y, z), chunkBounds(cx, cz, world.chunkHeight), palette, opts); +} \ No newline at end of file