feat(09): per-face UV studs + studDensity (паритет со студией)
All checks were successful
CI / Lint (pull_request) Successful in 56s
CI / Build (pull_request) Successful in 1m31s
CI / Secret scan (pull_request) Successful in 2m30s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped

faceUV для куба (кружки одного размера на всех гранях) + studDensity
(плотность кружков) — портировано из студии.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
МИН 2026-05-31 13:26:52 +03:00
parent d5968f7cb8
commit ae83926a5a

View File

@ -19,7 +19,7 @@
* При касании игроком обновляет spawnPoint сцены. * При касании игроком обновляет spawnPoint сцены.
*/ */
import { import {
MeshBuilder, StandardMaterial, Color3, Vector3, PointLight, MeshBuilder, StandardMaterial, Color3, Vector3, Vector4, PointLight,
Mesh, VertexData, Mesh, VertexData,
} from '@babylonjs/core'; } from '@babylonjs/core';
// CRA→Vite адаптация: оригинальный код в setTexture()/_applyMaterial()/ // CRA→Vite адаптация: оригинальный код в setTexture()/_applyMaterial()/
@ -47,8 +47,10 @@ function _getStudsTextures(scene) {
} }
return c; return c;
} }
function _studsTiling(type, sx, sy, sz) { function _studsTiling(type, sx, sy, sz, density) {
const f = STUD_UNIT * STUDS_GRID; // density — множитель плотности кружков (1=стандарт, 2=вдвое мельче/чаще).
const d = density && density > 0 ? density : 1;
const f = (STUD_UNIT * STUDS_GRID) / d;
let u = Math.max(sx, sz) / f; let u = Math.max(sx, sz) / f;
let v = sy / f; let v = sy / f;
if (type === 'cylinder') { u = (Math.PI * sx) / f; v = sy / f; } if (type === 'cylinder') { u = (Math.PI * sx) / f; v = sy / f; }
@ -56,6 +58,31 @@ function _studsTiling(type, sx, sy, sz) {
else if (type === 'plane') { u = sx / f; v = sz / f; } else if (type === 'plane') { u = sx / f; v = sz / f; }
return { u: Math.max(0.25, u), v: Math.max(0.25, v) }; return { u: Math.max(0.25, u), v: Math.max(0.25, v) };
} }
/**
* faceUV для куба со studs КАЖДАЯ грань тайлится по СВОИМ реальным размерам,
* чтобы кружки были одного размера на всех гранях (не растягивались на длинных).
* Грани CreateBox: 0=front(z-) 1=back(z+) 2=right(x+) 3=left(x-) 4=top(y+) 5=bottom(y-).
* front/back ширина=sx, высота=sy
* left/right ширина=sz, высота=sy
* top/bottom ширина=sx, высота=sz
* UV-диапазон грани = (0,0)..(кол-во_studs_по_ширине, кол-во_по_высоте).
*/
function _studsCubeFaceUV(sx, sy, sz, density) {
const d = density && density > 0 ? density : 1;
const f = (STUD_UNIT * STUDS_GRID) / d;
const nx = Math.max(0.25, sx / f); // studs вдоль X
const ny = Math.max(0.25, sy / f); // studs вдоль Y
const nz = Math.max(0.25, sz / f); // studs вдоль Z
// Vector4(u0, v0, u1, v1)
return [
new Vector4(0, 0, nx, ny), // front (z-): X×Y
new Vector4(0, 0, nx, ny), // back (z+): X×Y
new Vector4(0, 0, nz, ny), // right (x+): Z×Y
new Vector4(0, 0, nz, ny), // left (x-): Z×Y
new Vector4(0, 0, nx, nz), // top (y+): X×Z
new Vector4(0, 0, nx, nz), // bottom (y-): X×Z
];
}
export class PrimitiveManager { export class PrimitiveManager {
constructor(scene) { constructor(scene) {
@ -97,6 +124,8 @@ export class PrimitiveManager {
const isGlowingGd = isGdKind; const isGlowingGd = isGdKind;
const isGdSpike = typeDef.kind === 'gd_spike'; const isGdSpike = typeDef.kind === 'gd_spike';
const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte'); const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte');
// studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще).
const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1;
// canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции) // canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции)
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike; const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike;
const visible = opts.visible !== false; const visible = opts.visible !== false;
@ -114,7 +143,7 @@ export class PrimitiveManager {
const rotationY = opts.rotationY ?? 0; const rotationY = opts.rotationY ?? 0;
const rotationZ = opts.rotationZ ?? 0; const rotationZ = opts.rotationZ ?? 0;
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz); const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity);
mesh.position = new Vector3(x, y, z); mesh.position = new Vector3(x, y, z);
mesh.rotation = new Vector3(rotationX, rotationY, rotationZ); mesh.rotation = new Vector3(rotationX, rotationY, rotationZ);
mesh.isPickable = true; mesh.isPickable = true;
@ -138,14 +167,15 @@ export class PrimitiveManager {
id, mesh, type, x, y, z, sx, sy, sz, id, mesh, type, x, y, z, sx, sy, sz,
rotationX, rotationY, rotationZ, rotationX, rotationY, rotationZ,
color, material, canCollide, visible, anchored, mass, color, material, canCollide, visible, anchored, mass,
textureAsset, textureAsset, studDensity,
// locked — объект защищён от выделения/перемещения в редакторе // locked — объект защищён от выделения/перемещения в редакторе
// (Фаза 5.11). На геймплей не влияет. // (Фаза 5.11). На геймплей не влияет.
locked: opts.locked === true, locked: opts.locked === true,
name: opts.name || null, name: opts.name || null,
folderId: opts.folderId ?? null, folderId: opts.folderId ?? null,
}; };
mesh._studsDims = { type, sx, sy, sz }; // Размеры для тайлинга studs-материала (читается в _applyMaterial).
mesh._studsDims = { type, sx, sy, sz, density: studDensity };
this._applyMaterial(mesh, typeDef, color, material); this._applyMaterial(mesh, typeDef, color, material);
this._applyVisible(mesh, visible, typeDef); this._applyVisible(mesh, visible, typeDef);
// Пользовательская текстура — поверх базового материала. // Пользовательская текстура — поверх базового материала.
@ -210,13 +240,17 @@ export class PrimitiveManager {
} }
/** Создать базовый mesh нужной формы (без материала). */ /** Создать базовый mesh нужной формы (без материала). */
_createMeshForType(typeDef, id, sx, sy, sz) { _createMeshForType(typeDef, id, sx, sy, sz, material, studDensity) {
const name = `prim_${typeDef.id}_${id}`; const name = `prim_${typeDef.id}_${id}`;
switch (typeDef.id) { switch (typeDef.id) {
case 'cube': case 'cube':
case 'trigger': case 'trigger': {
return MeshBuilder.CreateBox(name, const boxOpts = { width: sx, height: sy, depth: sz };
{ width: sx, height: sy, depth: sz }, this.scene); // studs — per-face UV, чтобы кружки были одного размера на всех
// гранях (не растягивались на длинной стороне).
if (material === 'studs') boxOpts.faceUV = _studsCubeFaceUV(sx, sy, sz, studDensity);
return MeshBuilder.CreateBox(name, boxOpts, this.scene);
}
case 'sphere': case 'sphere':
return MeshBuilder.CreateSphere(name, return MeshBuilder.CreateSphere(name,
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene); { diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene);
@ -463,9 +497,16 @@ export class PrimitiveManager {
const dt = tex.diffuse.clone(); const dt = tex.diffuse.clone();
const nt = tex.normal.clone(); const nt = tex.normal.clone();
const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 }; const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 };
const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz); // Куб/триггер тайлятся через faceUV геометрии (uScale=1) — кружки
// одного размера на всех гранях. Остальные формы — через uScale.
if (dims.type === 'cube' || dims.type === 'trigger') {
dt.uScale = nt.uScale = 1;
dt.vScale = nt.vScale = 1;
} else {
const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz, dims.density);
dt.uScale = nt.uScale = tile.u; dt.uScale = nt.uScale = tile.u;
dt.vScale = nt.vScale = tile.v; dt.vScale = nt.vScale = tile.v;
}
mat.diffuseTexture = dt; mat.diffuseTexture = dt;
mat.bumpTexture = nt; mat.bumpTexture = nt;
const sc = Color3.FromHexString(color || '#cccccc'); const sc = Color3.FromHexString(color || '#cccccc');
@ -619,6 +660,12 @@ export class PrimitiveManager {
if (patch.sx !== undefined) { data.sx = patch.sx; scaleChanged = true; } if (patch.sx !== undefined) { data.sx = patch.sx; scaleChanged = true; }
if (patch.sy !== undefined) { data.sy = patch.sy; scaleChanged = true; } if (patch.sy !== undefined) { data.sy = patch.sy; scaleChanged = true; }
if (patch.sz !== undefined) { data.sz = patch.sz; scaleChanged = true; } if (patch.sz !== undefined) { data.sz = patch.sz; scaleChanged = true; }
// Плотность studs (мелкие/крупные кружки) — требует пересоздания меша
// (faceUV для куба зашит в геометрию).
if (patch.studDensity !== undefined) {
data.studDensity = Number.isFinite(patch.studDensity) ? patch.studDensity : 1;
scaleChanged = true;
}
if (scaleChanged) { if (scaleChanged) {
// Поскольку MeshBuilder уже создал mesh с базовыми размерами, // Поскольку MeshBuilder уже создал mesh с базовыми размерами,
// изменения через scaling кажутся правильными. Простой способ — // изменения через scaling кажутся правильными. Простой способ —
@ -749,7 +796,7 @@ export class PrimitiveManager {
const oldMat = oldMesh.material; const oldMat = oldMesh.material;
const typeDef = getPrimitiveType(data.type); const typeDef = getPrimitiveType(data.type);
const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz); const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz, data.material, data.studDensity);
newMesh.position = oldPos; newMesh.position = oldPos;
if (oldRot) newMesh.rotation = oldRot; if (oldRot) newMesh.rotation = oldRot;
newMesh.material = oldMat; newMesh.material = oldMat;
@ -762,9 +809,16 @@ export class PrimitiveManager {
catch (e) { /* ignore */ } catch (e) { /* ignore */ }
data.mesh = newMesh; data.mesh = newMesh;
newMesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz }; newMesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz, density: data.studDensity };
// studs-материал: пересчитать тайлинг под новый размер меша.
// Куб уже пересоздан с новым faceUV (тайлинг в геометрии) — uScale=1.
// Для остальных форм пересчитываем uScale/vScale по размеру.
if (data.material === 'studs' && oldMat && oldMat.diffuseTexture) { if (data.material === 'studs' && oldMat && oldMat.diffuseTexture) {
const tile = _studsTiling(data.type, data.sx, data.sy, data.sz); if (data.type === 'cube' || data.type === 'trigger') {
oldMat.diffuseTexture.uScale = oldMat.diffuseTexture.vScale = 1;
if (oldMat.bumpTexture) oldMat.bumpTexture.uScale = oldMat.bumpTexture.vScale = 1;
} else {
const tile = _studsTiling(data.type, data.sx, data.sy, data.sz, data.studDensity);
oldMat.diffuseTexture.uScale = tile.u; oldMat.diffuseTexture.uScale = tile.u;
oldMat.diffuseTexture.vScale = tile.v; oldMat.diffuseTexture.vScale = tile.v;
if (oldMat.bumpTexture) { if (oldMat.bumpTexture) {
@ -773,6 +827,7 @@ export class PrimitiveManager {
} }
} }
} }
}
/** Удалить инстанс. */ /** Удалить инстанс. */
removeInstance(id) { removeInstance(id) {
@ -820,6 +875,8 @@ export class PrimitiveManager {
...(d.locked ? { locked: true } : {}), ...(d.locked ? { locked: true } : {}),
// id пользовательской текстуры (картинка из AssetManager). // id пользовательской текстуры (картинка из AssetManager).
...(d.textureAsset ? { textureAsset: d.textureAsset } : {}), ...(d.textureAsset ? { textureAsset: d.textureAsset } : {}),
// Плотность studs (если не 1) — мелкие/крупные кружки.
...(d.studDensity && d.studDensity !== 1 ? { studDensity: d.studDensity } : {}),
// Параметры лампы (только для type='light', иначе undefined) // Параметры лампы (только для type='light', иначе undefined)
...(d.light ? { brightness: d.brightness, range: d.range } : {}), ...(d.light ? { brightness: d.brightness, range: d.range } : {}),
// Параметр эмиттера (только для type='emitter') // Параметр эмиттера (только для type='emitter')